From e78f2808e2242090b41e5fd78822d5b930d2343e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 15:58:43 +0900 Subject: [PATCH 01/19] =?UTF-8?q?fix:=20v1=20=EC=96=B4=EB=8C=91=ED=84=B0?= =?UTF-8?q?=20ID=20=EC=8A=B9=EA=B3=84=EB=A5=BC=20index=C2=B7=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=AA=A8=EB=93=9C=20=EC=9A=B0=EC=84=A0=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit id 없는 v1 패치에서 값 매칭만으로 ID를 승계하면 두 가지 문제가 생긴다. 같은 길이 positions 단독 패치에서 값이 바뀐 요소가 새 ID를 받아 id 순서가 흔들리고(paired 검사가 통상적인 플러그인 위치 업데이트를 거부), 값 매칭이 전 모드를 훑어 한 모드의 ID가 다른 모드 요소로 옮겨간다. 승계 순서를 같은 모드 index(길이 동일) → 같은 모드 값 → 전역 값 폴백으로 정렬한다. Co-Authored-By: Claude Fable 5 --- src-tauri/src/state/native_element_id.rs | 160 +++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 157c4e32..b7819af4 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -325,6 +325,38 @@ fn keep_or_rekey_supplied_ids( } } +// 같은 모드 안에서 빈 ID를 값으로 승계한다. 모드를 넘는 승계는 무관한 모드의 +// 신원을 빼앗으므로 이 패스에서 제외한다 +fn inherit_ids_by_value_within_mode( + current: &HashMap>, + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let Some(current_elements) = current.get(&mode) else { + continue; + }; + for element in elements { + if !element.position().id.is_empty() { + continue; + } + let inherited = current_elements.iter().find(|current_element| { + let current_id = ¤t_element.position().id; + !consumed_current_ids.contains(current_id) + && same_value_without_id(*current_element, &*element) + }); + if let Some(current_element) = inherited { + let id = current_element.position().id.clone(); + consumed_current_ids.insert(id.clone()); + element.position_mut().id = id; + } + } + } +} + fn inherit_ids_by_value( current_elements: &[T], candidate: &mut HashMap>, @@ -355,6 +387,37 @@ fn inherit_ids_by_value( } } +// 길이가 같은 모드는 index가 신원이다(v1 전체 레코드 쓰기의 master 의미론). +// 값이 바뀐 요소도 제자리 ID를 지켜야 재발급이 신원 회전으로 번지지 않는다 +fn inherit_ids_by_index( + current: &HashMap>, + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let Some(current_elements) = current.get(&mode) else { + continue; + }; + if current_elements.len() != elements.len() { + continue; + } + for (element, current_element) in elements.iter_mut().zip(current_elements) { + if !element.position().id.is_empty() { + continue; + } + let id = current_element.position().id.clone(); + if id.is_empty() || consumed_current_ids.contains(&id) { + continue; + } + consumed_current_ids.insert(id.clone()); + element.position_mut().id = id; + } + } +} + fn adapt_v1_collection( current: &HashMap>, candidate: &mut HashMap>, @@ -363,6 +426,9 @@ fn adapt_v1_collection( reserved: &mut HashSet, ) { keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); + // 승계 순서: 같은 모드 index(길이 동일) → 같은 모드 값 → 전역 값 폴백 + inherit_ids_by_index(current, candidate, consumed_current_ids); + inherit_ids_by_value_within_mode(current, candidate, consumed_current_ids); inherit_ids_by_value( &ordered_current_elements(current), candidate, @@ -917,6 +983,100 @@ mod tests { assert_ne!(positions[2].id, second_id); } + #[test] + fn v1_idless_positions_only_value_change_keeps_ids_by_index() { + let store = store_with_all_collections(); + let original = store.key_positions["mode"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + let mut patch = EditorPatchV1 { + key_positions: Some(HashMap::from([( + "mode".to_string(), + vec![position(50.0), position(2.0)], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 같은 길이 positions 단독 패치는 index가 신원 - 값이 바뀐 요소도 ID를 + // 유지해 id 순서가 보존되고 paired id-order 검사와 충돌하지 않는다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, original[0]); + assert_eq!(positions[1].id, original[1]); + } + + #[test] + fn v1_idless_positions_only_swap_is_treated_as_in_place_edits() { + let store = store_with_all_collections(); + let original = store.key_positions["mode"] + .iter() + .map(|position| position.id.clone()) + .collect::>(); + let mut patch = EditorPatchV1 { + key_positions: Some(HashMap::from([( + "mode".to_string(), + vec![position(2.0), position(1.0)], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 같은 길이 값 스왑은 두 건의 제자리 값 편집으로 해석 - keys 미동반 + // 패치가 신원 재배열로 번지지 않는다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, original[0]); + assert_eq!(positions[1].id, original[1]); + } + + #[test] + fn v1_idless_patch_prefers_same_mode_and_never_steals_across_modes() { + let mut store = AppStoreData { + stat_positions: HashMap::from([ + ("modeA".to_string(), Vec::new()), + ( + "modeB".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(10.0), + }], + ), + ]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let id_b = store.stat_positions["modeB"][0].position.id.clone(); + let mut patch = EditorPatchV1 { + stat_positions: Some(HashMap::from([ + ( + "modeA".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(10.0), + }], + ), + ( + "modeB".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position(10.0), + }], + ), + ])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 레이아웃을 다른 모드로 복사해도 원본 모드가 자기 ID를 지킨다 + let positions = patch.stat_positions.unwrap(); + assert_eq!(positions["modeB"][0].position.id, id_b); + assert_ne!(positions["modeA"][0].position.id, id_b); + assert!(is_valid_element_id(&positions["modeA"][0].position.id)); + } + fn keyed_store(slots: Vec, positions: Vec) -> AppStoreData { let mut store = AppStoreData { keys: HashMap::from([("mode".to_string(), slots)]), From 7786ae20c940cea178d1a4aa56e3e3aad433894b Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:00:56 +0900 Subject: [PATCH 02/19] =?UTF-8?q?fix:=20=EC=8A=AC=EB=A1=AF=EC=9D=B4=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=EC=9D=B8=20=ED=82=A4=EC=9D=98=20?= =?UTF-8?q?=EA=B0=92=20=EB=B3=80=EA=B2=BD=EC=97=90=EC=84=9C=20ID=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paired v1 패치의 승계 웨이브가 모두 값 일치를 요구해, 키 슬롯은 그대로인데 위치 값만 바뀐 요소가 ID를 상속받지 못하고 새로 발급받았다. 이동이 신원 회전으로 번져 선택·바인딩이 끊긴다. 값을 무시하고 같은 모드·같은 index의 슬롯 일치만 보는 웨이브를 값 폴백 앞에 추가한다. index를 고정해 중복 슬롯끼리 교차 승계하지 않는다. Co-Authored-By: Claude Fable 5 --- src-tauri/src/state/native_element_id.rs | 86 +++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index b7819af4..48a37833 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -439,6 +439,7 @@ fn adapt_v1_collection( struct SlotPairedPosition { mode: String, + index: usize, slot: Option, position: KeyPosition, } @@ -456,6 +457,7 @@ fn slot_paired_current_positions( for (index, element) in elements.iter().enumerate() { pairs.push(SlotPairedPosition { mode: mode.clone(), + index, slot: slots.and_then(|slots| slots.get(index)).cloned(), position: element.clone(), }); @@ -504,6 +506,42 @@ fn consume_slot_paired_ids( } } +// 값을 무시하고 같은 모드·같은 index의 슬롯 일치만으로 승계한다. 값 일치를 +// 요구하는 앞 웨이브들이 모두 실패하는 경우 - 슬롯은 그대로인데 위치 값만 +// 바뀐 이동 - 를 담당한다. index를 고정해 중복 슬롯끼리 교차 승계하지 않는다 +fn consume_slot_only_ids( + candidate: &mut HashMap>, + patch_keys: &KeyMappings, + current_pairs: &[SlotPairedPosition], + consumed_current_ids: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get_mut(&mode) else { + continue; + }; + let slots = patch_keys.get(&mode); + for (index, element) in elements.iter_mut().enumerate() { + if !element.id.is_empty() { + continue; + } + let Some(slot) = slots.and_then(|slots| slots.get(index)) else { + continue; + }; + let inherited = current_pairs.iter().find(|pair| { + pair.mode == mode + && pair.index == index + && pair.slot.as_ref() == Some(slot) + && !consumed_current_ids.contains(&pair.position.id) + }); + if let Some(pair) = inherited { + let id = pair.position.id.clone(); + consumed_current_ids.insert(id.clone()); + element.id = id; + } + } + } +} + // v1 paired patch는 keys[i]-keyPositions[i] 결합이 신원 단서다. 값이 같은 // 위치가 여럿일 때 값만으로 승계하면 재정렬에서 ID가 다른 키 슬롯에 붙으므로 // 같은 모드의 (슬롯, 값) 정확 일치부터 소진하고, 모드 이동·재바인딩은 @@ -531,7 +569,8 @@ fn adapt_v1_key_position_ids( let current_pairs = slot_paired_current_positions(&store.keys, &store.key_positions); // 웨이브 순서: 같은 모드 슬롯+값 → 모드 간 슬롯+값(모드 이동) → - // 같은 모드 값(재바인딩) → 마지막 전역 값 폴백과 신규 발급 + // 같은 모드 값(재바인딩) → 같은 모드 index 슬롯(값 변경 이동) → + // 마지막 전역 값 폴백과 신규 발급 for (match_slot, same_mode_only) in [(true, true), (true, false), (false, true)] { consume_slot_paired_ids( candidate, @@ -542,7 +581,9 @@ fn adapt_v1_key_position_ids( same_mode_only, ); } + consume_slot_only_ids(candidate, patch_keys, ¤t_pairs, consumed_current_ids); + inherit_ids_by_value_within_mode(&store.key_positions, candidate, consumed_current_ids); inherit_ids_by_value( &ordered_current_elements(&store.key_positions), candidate, @@ -1156,6 +1197,49 @@ mod tests { assert_eq!(positions[0].id, id_b); } + #[test] + fn v1_paired_slot_keeps_id_when_only_the_position_value_changes() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(1.0), position(2.0)], + ); + let id_a = store.key_positions["mode"][0].id.clone(); + let id_b = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(50.0), position(2.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯이 그대로면 위치 값이 바뀌어도 신원이 유지된다 - 이동은 신원 + // 회전이 아니다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, id_a); + assert_eq!(positions[1].id, id_b); + } + + #[test] + fn v1_paired_duplicate_slots_keep_their_own_ids_by_index() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("A")], + vec![position(1.0), position(2.0)], + ); + let first = store.key_positions["mode"][0].id.clone(); + let second = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("A")], + vec![position(10.0), position(20.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯이 겹쳐도 index 정렬로 각자 자기 ID를 지킨다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, first); + assert_eq!(positions[1].id, second); + } + #[test] fn v1_paired_rebind_never_steals_ids_from_other_modes() { let mut store = AppStoreData { From c197f280a6bdc13c070b5241f6b7cdd228e62ec1 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:03:42 +0900 Subject: [PATCH 03/19] =?UTF-8?q?fix:=20v1=20=ED=8C=A8=EC=B9=98=EC=9D=98?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=C2=B7=ED=83=80=20=EC=BB=AC=EB=A0=89?= =?UTF-8?q?=EC=85=98=20id=EB=A5=BC=20=EA=B1=B0=EB=B6=80=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit id가 플러그인 읽기에 노출되면서, 읽어온 위치 객체를 복사해 요소를 늘리는 관용 패턴이 DUPLICATE_ELEMENT_ID 비재시도 거부를 받게 됐다. 타 컬렉션의 id를 실은 패치도 전역 유일성 검사만 통과하면 신원이 이식됐다. v1 경로에서 소유가 어긋나거나 패치 안에서 중복된 id는 비워서 승계·신규 발급 경로로 넘긴다. 중복은 canonical 자리를 원본으로 보고 사본만 비운다. 형식이 잘못된 id는 호출부 버그 신호이므로 거부를 유지한다. Co-Authored-By: Claude Fable 5 --- src-tauri/src/state/native_element_id.rs | 139 +++++++++++++++++++++-- 1 file changed, 132 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 48a37833..30f28e32 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -283,6 +283,99 @@ fn validate_supplied_patch_ids( Ok(seen) } +// v1은 id를 모르는 입력을 계속 받는 계약이라, 형식이 유효한데 소유가 어긋나거나 +// (다른 컬렉션·삭제된 신원) 패치 안에서 중복된 id는 거부 대신 비워서 뒤의 승계· +// 신규 발급 경로로 넘긴다. 중복은 canonical 자리를 원본으로 보고 사본만 비운다 +fn sanitize_v1_supplied_collection_ids( + candidate: &mut HashMap>, + current: &HashMap>, +) -> Result<(), EditorCommitError> { + let mut owned = HashMap::new(); + for mode in sorted_modes(current) { + let Some(elements) = current.get(&mode) else { + continue; + }; + for (index, element) in elements.iter().enumerate() { + let id = &element.position().id; + if !id.is_empty() { + owned.insert(id.clone(), (mode.clone(), index)); + } + } + } + + let mut occurrences: HashMap> = HashMap::new(); + for mode in sorted_modes(candidate) { + let Some(elements) = candidate.get(&mode) else { + continue; + }; + for (index, element) in elements.iter().enumerate() { + let id = &element.position().id; + if id.is_empty() { + continue; + } + if !is_valid_element_id(id) { + return Err(EditorCommitError::validation( + INVALID_ELEMENT_ID, + format!("native element {mode}[{index}] has an invalid ID"), + )); + } + occurrences + .entry(id.clone()) + .or_default() + .push((mode.clone(), index)); + } + } + + let mut cleared = HashSet::new(); + for (id, slots) in &occurrences { + let Some(canonical_slot) = owned.get(id) else { + cleared.extend(slots.iter().cloned()); + continue; + }; + if slots.len() == 1 { + continue; + } + let keeper = slots + .iter() + .find(|slot| *slot == canonical_slot) + .unwrap_or(&slots[0]); + for slot in slots { + if slot != keeper { + cleared.insert(slot.clone()); + } + } + } + + for (mode, index) in cleared { + if let Some(element) = candidate + .get_mut(&mode) + .and_then(|elements| elements.get_mut(index)) + { + element.position_mut().id.clear(); + } + } + Ok(()) +} + +fn sanitize_v1_supplied_patch_ids( + store: &AppStoreData, + patch: &mut EditorPatchV1, +) -> Result<(), EditorCommitError> { + if let Some(collection) = patch.key_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.key_positions)?; + } + if let Some(collection) = patch.stat_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.stat_positions)?; + } + if let Some(collection) = patch.graph_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.graph_positions)?; + } + if let Some(collection) = patch.knob_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.knob_positions)?; + } + Ok(()) +} + fn same_value_without_id(left: &T, right: &T) -> bool { let mut left = left.clone(); let mut right = right.clone(); @@ -596,6 +689,9 @@ fn adapt_v1_patch_ids( store: &AppStoreData, patch: &mut EditorPatchV1, ) -> Result<(), EditorCommitError> { + sanitize_v1_supplied_patch_ids(store, patch)?; + // 정화 후에는 남은 id가 모두 자기 컬렉션 소유이자 유일 - 이 호출은 수집과 + // 불변식 확인을 겸한다 let supplied_ids = validate_supplied_patch_ids(patch, false)?; let canonical_ids = collect_store_ids(store); let mut consumed_current_ids = supplied_ids @@ -1324,7 +1420,7 @@ mod tests { } #[test] - fn v1_rejects_nil_non_uuid_and_duplicate_supplied_ids() { + fn v1_rejects_nil_and_non_uuid_supplied_ids() { let store = store_with_all_collections(); for invalid_id in [Uuid::nil().to_string(), "not-a-uuid".to_string()] { let mut positions = store.key_positions.clone(); @@ -1338,17 +1434,46 @@ mod tests { INVALID_ELEMENT_ID ); } + } + #[test] + fn v1_duplicating_a_read_element_rekeys_the_copy_instead_of_failing() { + let store = store_with_all_collections(); + let original = store.key_positions["mode"][0].id.clone(); + // 플러그인의 관용 패턴: 읽어온 위치 객체를 펼쳐 복사해 배열에 덧붙인다 let mut positions = store.key_positions.clone(); - let duplicate = positions["mode"][0].id.clone(); - positions.get_mut("mode").unwrap()[1].id = duplicate; + let mut copy = positions["mode"][0].clone(); + copy.dx = 300.0; + positions.get_mut("mode").unwrap().push(copy); let mut patch = EditorPatchV1 { key_positions: Some(positions), ..EditorPatchV1::default() }; - assert_eq!( - validation_code(prepare_commit_patch_element_ids(&store, &mut patch).unwrap_err()), - DUPLICATE_ELEMENT_ID - ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, original); + assert_ne!(positions[2].id, original); + assert!(is_valid_element_id(&positions[2].id)); + } + + #[test] + fn v1_supplied_id_from_another_collection_is_rekeyed() { + let store = store_with_all_collections(); + let stat_id = store.stat_positions["mode"][0].position.id.clone(); + let mut positions = store.key_positions.clone(); + positions.get_mut("mode").unwrap()[0].id = stat_id.clone(); + let mut patch = EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 다른 컬렉션의 신원을 키 위치로 이식할 수 없다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_ne!(positions[0].id, stat_id); + assert!(is_valid_element_id(&positions[0].id)); } } From 030b03688c04ad7baf17394468f3cb586079db77 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:07:00 +0900 Subject: [PATCH 04/19] =?UTF-8?q?fix:=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=EA=B2=BD=EA=B3=84=EC=97=90=EC=84=9C=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=20ID=20=EB=B0=B1=ED=95=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stat/graph/knob_positions_update 같은 레거시 커맨드는 호출부가 넘긴 레코드를 검증 없이 대입해, id가 빈 요소가 canonical store에 들어가 영속·전파됐다. 그 뒤로는 네이티브 컬렉션을 건드리는 모든 v2 커밋이 MISSING_ELEMENT_ID로 거부되어 오염 컬렉션과 무관한 편집까지 막혔다. 레거시 트랜잭션 경계에서 선언된 컬렉션에 한해 빈·중복·형식 오류 id를 채운다. 제자리 id 치환이라 keys[i]↔keyPositions[i] 결합은 유지된다. Co-Authored-By: Claude Fable 5 --- src-tauri/src/state/native_element_id.rs | 80 +++++++++++++++++------- src-tauri/src/state/store.rs | 69 +++++++++++++++++++- 2 files changed, 124 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 30f28e32..df3f3676 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -114,33 +114,65 @@ fn backfill_collection( } pub(crate) fn backfill_store_element_ids(store: &mut AppStoreData) -> BackfillOutcome { + backfill_element_ids_for_collections(store, true, true, true, true) +} + +// 건너뛰는 컬렉션의 id를 seen에 먼저 채워, 대상 컬렉션의 교차 중복도 복구된다 +pub(crate) fn backfill_element_ids_for_collections( + store: &mut AppStoreData, + key_positions: bool, + stat_positions: bool, + graph_positions: bool, + knob_positions: bool, +) -> BackfillOutcome { let mut seen = HashSet::new(); + if !key_positions { + collect_collection_ids(&store.key_positions, &mut seen); + } + if !stat_positions { + collect_collection_ids(&store.stat_positions, &mut seen); + } + if !graph_positions { + collect_collection_ids(&store.graph_positions, &mut seen); + } + if !knob_positions { + collect_collection_ids(&store.knob_positions, &mut seen); + } + let mut reserved = collect_store_ids(store); let mut outcome = BackfillOutcome::default(); - backfill_collection( - &mut store.key_positions, - &mut seen, - &mut reserved, - &mut outcome, - ); - backfill_collection( - &mut store.stat_positions, - &mut seen, - &mut reserved, - &mut outcome, - ); - backfill_collection( - &mut store.graph_positions, - &mut seen, - &mut reserved, - &mut outcome, - ); - backfill_collection( - &mut store.knob_positions, - &mut seen, - &mut reserved, - &mut outcome, - ); + if key_positions { + backfill_collection( + &mut store.key_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if stat_positions { + backfill_collection( + &mut store.stat_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if graph_positions { + backfill_collection( + &mut store.graph_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if knob_positions { + backfill_collection( + &mut store.knob_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + } outcome } diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 2924713e..9cd98922 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -1731,6 +1731,16 @@ impl AppStore { } let value = updater(&mut scratch)?; crate::state::migration::canonicalize_gradient_pairs(&mut scratch); + // 레거시 경로는 호출부가 넘긴 전체 레코드를 그대로 대입한다 - 선언된 + // 컬렉션에 한해 빈·중복·형식 오류 id를 채워 canonical 오염을 막는다 + // (선언 밖 컬렉션을 건드리면 UNDECLARED_EDITOR_FIELD가 된다) + crate::state::native_element_id::backfill_element_ids_for_collections( + &mut scratch, + touched_fields.contains(&EditorField::KeyPositions), + touched_fields.contains(&EditorField::StatPositions), + touched_fields.contains(&EditorField::GraphPositions), + touched_fields.contains(&EditorField::KnobPositions), + ); // editorRevision은 이 트랜잭션만 관리 scratch.editor_revision = current_store.editor_revision; @@ -8211,6 +8221,52 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn legacy_full_record_write_backfills_element_ids_before_they_reach_canonical() { + let dir = test_directory("editor-legacy-id-backfill-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mode = store.snapshot().selected_key_type; + + // 레거시 전체 레코드 커맨드는 호출부가 준 요소를 그대로 대입한다 - + // id 없는 stat이 canonical에 남으면 이후 v2 커밋이 전부 막힌다 + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("stat_positions_update".to_string()), + &[EditorField::StatPositions], + |data| { + data.stat_positions.insert( + mode.clone(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }], + ); + Ok(()) + }, + ) + .unwrap(); + + let snapshot = store.snapshot(); + let stored_id = snapshot.stat_positions[&mode][0].position.id.clone(); + assert!(crate::state::native_element_id::is_valid_element_id( + &stored_id + )); + + // 오염이 없으니 후속 v2 커밋이 통과한다 + store + .commit_editor_document(editor_request( + store.snapshot().editor_revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, 42.0), + )) + .unwrap(); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn strict_editor_commit_preserves_existing_ghost_modes_losslessly() { let dir = test_directory("editor-grandfather-test"); @@ -8391,7 +8447,18 @@ mod tests { let positions = change.document.key_positions; assert_eq!(store.writer.persist_count(), persist_count + 1); assert_eq!(keys["4key"].last().unwrap(), &KeySlot::from("F5")); - assert_eq!(positions["4key"].last().unwrap(), &KeyPosition::default()); + // 덧붙은 위치는 기본값 그대로이되 id만 경계에서 채워진다 + let appended = positions["4key"].last().unwrap(); + assert!(crate::state::native_element_id::is_valid_element_id( + &appended.id + )); + assert_eq!( + &KeyPosition { + id: String::new(), + ..appended.clone() + }, + &KeyPosition::default() + ); assert!(keys["5key"].last().unwrap().is_unassigned()); assert_eq!(keys["4key"].len(), positions["4key"].len()); assert_eq!(keys["5key"].len(), positions["5key"].len()); From f6e6263284d65555f0e2e62733be9832e80b222d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:09:49 +0900 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20=ED=83=AD=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EC=85=8B=EC=9D=B4=20=EC=95=88=20=EC=A4=80=20=EC=BB=AC=EB=A0=89?= =?UTF-8?q?=EC=85=98=EC=9D=98=20=EC=9A=94=EC=86=8C=20ID=EB=A5=BC=20?= =?UTF-8?q?=ED=9A=8C=EC=A0=84=EC=8B=9C=ED=82=A4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rekey_tab_preset_elements가 stat/graph/knob은 기록 여부로 게이트하면서 key_positions만 무조건 true를 넘겼다. keys만 담긴 탭 프리셋에서 merge가 기존 위치를 값 그대로 되삽입해도 모든 키의 신원이 회전해, 선택·진행 중인 picker 완료·크로스 윈도우 선택이 한꺼번에 끊겼다. 네 컬렉션 모두 기록 여부로 게이트하고, 프리셋이 위치를 주지 않은 컬렉션은 정렬이 덧붙인 빈 항목만 백필한다. Co-Authored-By: Claude Fable 5 --- src-tauri/src/commands/preset/load.rs | 48 ++++++++++- src-tauri/src/state/native_element_id.rs | 102 +++++++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 4421e2f5..328ac47c 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -611,6 +611,7 @@ pub fn preset_load_tab( admission, move |store| { let previous_tab_css_overrides = store.tab_css_overrides.clone(); + let key_positions_written = imported_key_positions.is_some(); let stat_positions_written = imported_stat_positions.is_some(); let graph_positions_written = imported_graph_positions.is_some(); let knob_positions_written = imported_knob_positions.is_some(); @@ -633,6 +634,7 @@ pub fn preset_load_tab( rekey_tab_preset_elements( store, ¤t_tab_id, + key_positions_written, stat_positions_written, graph_positions_written, knob_positions_written, @@ -908,6 +910,7 @@ fn rekey_full_preset_elements(store: &mut AppStoreData) { fn rekey_tab_preset_elements( store: &mut AppStoreData, tab_id: &str, + key_positions_written: bool, stat_positions_written: bool, graph_positions_written: bool, knob_positions_written: bool, @@ -915,11 +918,21 @@ fn rekey_tab_preset_elements( crate::state::native_element_id::rekey_mode_element_ids_for_collections( store, tab_id, - true, + key_positions_written, stat_positions_written, graph_positions_written, knob_positions_written, ); + // 프리셋이 위치를 주지 않은 컬렉션은 기존 요소가 값 그대로 남는다 - + // 신원을 회전시키지 않고 정렬이 덧붙인 빈 항목만 채운다 + crate::state::native_element_id::backfill_mode_element_ids_for_collections( + store, + tab_id, + !key_positions_written, + !stat_positions_written, + !graph_positions_written, + !knob_positions_written, + ); } fn merge_tab_preset_key_pair( @@ -2365,9 +2378,9 @@ mod tests { let untouched_id = store.key_positions["untouched"][0].id.clone(); let original_ids = target_preset_ids(&store); - rekey_tab_preset_elements(&mut store, "target", true, true, false); + rekey_tab_preset_elements(&mut store, "target", true, true, true, false); let first_ids = target_preset_ids(&store); - rekey_tab_preset_elements(&mut store, "target", true, true, false); + rekey_tab_preset_elements(&mut store, "target", true, true, true, false); let second_ids = target_preset_ids(&store); assert!(original_ids[..3] @@ -2387,6 +2400,35 @@ mod tests { .unwrap(); } + #[test] + fn tab_preset_without_key_positions_keeps_existing_key_ids() { + let mut store = old_preset_store(); + crate::state::native_element_id::backfill_store_element_ids(&mut store); + let original_ids = target_preset_ids(&store); + // keys만 담긴 탭 프리셋: merge가 기존 위치를 값 그대로 되삽입하고 + // 슬롯 정렬이 빈 위치 하나를 덧붙인 상태 + store + .key_positions + .get_mut("target") + .unwrap() + .push(KeyPosition::default()); + + rekey_tab_preset_elements(&mut store, "target", false, false, false, false); + + // 값이 그대로인 기존 키는 신원을 지키고, 덧붙은 슬롯만 새 id를 받는다 + assert_eq!(store.key_positions["target"][0].id, original_ids[0]); + let appended = &store.key_positions["target"][1].id; + assert!(crate::state::native_element_id::is_valid_element_id( + appended + )); + assert_ne!(appended, &original_ids[0]); + assert_eq!(target_preset_ids(&store)[1..], original_ids[1..]); + crate::state::native_element_id::validate_document_element_ids( + &crate::models::EditorDocumentV1::from_store(&store), + ) + .unwrap(); + } + #[test] fn preset_import_alignment_repairs_each_mode_without_dropping_values() { let mut keys = KeyMappings::from([ diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index df3f3676..75747e45 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -258,6 +258,108 @@ pub(crate) fn rekey_mode_element_ids_for_collections( } } +fn backfill_collection_mode( + collection: &mut HashMap>, + mode: &str, + seen: &mut HashSet, + reserved: &mut HashSet, + outcome: &mut BackfillOutcome, +) { + let Some(elements) = collection.get_mut(mode) else { + return; + }; + for element in elements { + let id = &element.position().id; + let valid = is_valid_element_id(id); + if valid && seen.insert(id.clone()) { + continue; + } + + if !id.is_empty() || valid { + outcome.repaired = true; + } + let id = new_unique_id(reserved); + seen.insert(id.clone()); + element.position_mut().id = id; + outcome.changed = true; + } +} + +// 한 모드의 선택된 컬렉션만 채운다. 대상 밖 요소의 id를 seen에 먼저 담아 +// 대상 안의 중복도 복구되고 새 id가 문서 전체에서 유일하도록 유지한다 +pub(crate) fn backfill_mode_element_ids_for_collections( + store: &mut AppStoreData, + mode: &str, + key_positions: bool, + stat_positions: bool, + graph_positions: bool, + knob_positions: bool, +) -> BackfillOutcome { + let mut seen = HashSet::new(); + collect_collection_ids_outside_target(&store.key_positions, mode, key_positions, &mut seen); + collect_collection_ids_outside_target(&store.stat_positions, mode, stat_positions, &mut seen); + collect_collection_ids_outside_target(&store.graph_positions, mode, graph_positions, &mut seen); + collect_collection_ids_outside_target(&store.knob_positions, mode, knob_positions, &mut seen); + let mut reserved = collect_store_ids(store); + let mut outcome = BackfillOutcome::default(); + if key_positions { + backfill_collection_mode( + &mut store.key_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if stat_positions { + backfill_collection_mode( + &mut store.stat_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if graph_positions { + backfill_collection_mode( + &mut store.graph_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + if knob_positions { + backfill_collection_mode( + &mut store.knob_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + } + outcome +} + +// 백필 대상(선택된 컬렉션 × 대상 모드) 밖의 id만 모은다 +fn collect_collection_ids_outside_target( + collection: &HashMap>, + target_mode: &str, + targeted: bool, + ids: &mut HashSet, +) { + for (mode, elements) in collection { + if targeted && mode == target_mode { + continue; + } + for element in elements { + if is_valid_element_id(&element.position().id) { + ids.insert(element.position().id.clone()); + } + } + } +} + fn validate_supplied_collection_ids( collection: &HashMap>, require_id: bool, From 8e2621dce014e86a5ce6828f6a7d371318c65fd3 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:14:29 +0900 Subject: [PATCH 06/19] =?UTF-8?q?fix:=20=ED=94=84=EB=A6=AC=EC=85=8B=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=9D=98=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=20=EC=B4=88=EA=B3=BC=20=EA=B4=80=EC=9A=A9=EC=9D=84=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한도 초과 관용이 (mode,index)에서 안정 ID 조회로 바뀌었는데, 프리셋 로드는 검증 직전 모든 요소의 id를 재발급한다. 값이 그대로여도 관용 상대를 찾지 못해, 그랜드파더된 값을 가진 store에서 자기 프리셋 백업을 다시 불러오면 전체 임포트가 비재시도 실패했다. 프리셋 스냅샷 복원(undo)도 같은 기전이다. 관용 신원 기준을 GrandfatherKeying으로 명시하고 프리셋 트랜잭션에서만 (모드, index) 짝짓기를 쓴다. 일반 편집 경로는 ID 기준을 그대로 유지해 새 요소가 남의 관용을 물려받지 못한다. Co-Authored-By: Claude Fable 5 --- src-tauri/src/state/editor.rs | 159 ++++++++++++++++++++++++++++++---- src-tauri/src/state/store.rs | 29 ++++++- 2 files changed, 169 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index 9b81550f..b68896f3 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -448,11 +448,36 @@ fn key_position_id_order( } /// 기존 store에 있던 손실 없는 비정상 데이터는 유지하되 새 비정상 상태는 만들지 않음 +// 한도 초과 관용의 신원 기준. 평상시에는 안정 ID로 요소를 짝지어, 새 요소가 +// 남의 관용을 물려받지 못하게 한다. 프리셋 트랜잭션은 커밋 직전 모든 id를 +// 재발급하므로 ID 짝짓기가 성립하지 않아 (모드, index)로 되돌린다 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GrandfatherKeying { + ById, + ByModeIndex, +} + pub(crate) fn validate_document_transition( current: &EditorDocumentV1, candidate: &EditorDocumentV1, current_store: &AppStoreData, candidate_store: &AppStoreData, +) -> Result<(), EditorCommitError> { + validate_document_transition_with_keying( + current, + candidate, + current_store, + candidate_store, + GrandfatherKeying::ById, + ) +} + +pub(crate) fn validate_document_transition_with_keying( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, + current_store: &AppStoreData, + candidate_store: &AppStoreData, + keying: GrandfatherKeying, ) -> Result<(), EditorCommitError> { if candidate.schema_version != EDITOR_SCHEMA_VERSION { return Err(EditorCommitError::validation( @@ -470,7 +495,7 @@ pub(crate) fn validate_document_transition( .iter() .map(|violation| violation.key.clone()) .collect::>(); - validate_metric_limits(current, candidate)?; + validate_metric_limits(current, candidate, keying)?; if let Some(violation) = candidate_violations.iter().find(|violation| { is_unconditional_structural_violation(violation.code()) @@ -823,10 +848,11 @@ fn collect_collection_violations( fn validate_metric_limits( current: &EditorDocumentV1, candidate: &EditorDocumentV1, + keying: GrandfatherKeying, ) -> Result<(), EditorCommitError> { validate_aggregate_metric_limits(current, candidate)?; validate_mode_metric_limits(current, candidate)?; - validate_per_owner_metric_limits(current, candidate) + validate_per_owner_metric_limits(current, candidate, keying) } fn validate_aggregate_metric_limits( @@ -908,9 +934,30 @@ fn validate_mode_metric_limits( Ok(()) } +// keying에 따라 관용 상대를 찾는다. ById는 안정 ID로, ByModeIndex는 같은 +// 모드의 같은 자리로 짝짓는다 +fn grandfather_counterpart<'a, T>( + keying: GrandfatherKeying, + by_id: &HashMap<&str, &'a T>, + current_collection: &'a HashMap>, + mode: &str, + index: usize, + id: &str, + position_of: impl Fn(&'a T) -> &'a KeyPosition, +) -> Option<&'a KeyPosition> { + match keying { + GrandfatherKeying::ById => by_id.get(id).map(|element| position_of(element)), + GrandfatherKeying::ByModeIndex => current_collection + .get(mode) + .and_then(|elements| elements.get(index)) + .map(position_of), + } +} + fn validate_per_owner_metric_limits( current: &EditorDocumentV1, candidate: &EditorDocumentV1, + keying: GrandfatherKeying, ) -> Result<(), EditorCommitError> { let mut current_key_slots = HashMap::new(); for (mode, positions) in ¤t.key_positions { @@ -977,7 +1024,15 @@ fn validate_per_owner_metric_limits( "keyPositions", mode, index, - current_key_positions.get(position.id.as_str()).copied(), + grandfather_counterpart( + keying, + ¤t_key_positions, + ¤t.key_positions, + mode, + index, + &position.id, + |position| position, + ), position, )?; } @@ -987,7 +1042,7 @@ fn validate_per_owner_metric_limits( .stat_positions .values() .flatten() - .map(|position| (position.position.id.as_str(), &position.position)) + .map(|position| (position.position.id.as_str(), position)) .collect::>(); for (mode, positions) in &candidate.stat_positions { for (index, position) in positions.iter().enumerate() { @@ -995,9 +1050,15 @@ fn validate_per_owner_metric_limits( "statPositions", mode, index, - current_stat_positions - .get(position.position.id.as_str()) - .copied(), + grandfather_counterpart( + keying, + ¤t_stat_positions, + ¤t.stat_positions, + mode, + index, + &position.position.id, + |element| &element.position, + ), &position.position, )?; } @@ -1007,7 +1068,7 @@ fn validate_per_owner_metric_limits( .graph_positions .values() .flatten() - .map(|position| (position.position.id.as_str(), &position.position)) + .map(|position| (position.position.id.as_str(), position)) .collect::>(); for (mode, positions) in &candidate.graph_positions { for (index, position) in positions.iter().enumerate() { @@ -1015,9 +1076,15 @@ fn validate_per_owner_metric_limits( "graphPositions", mode, index, - current_graph_positions - .get(position.position.id.as_str()) - .copied(), + grandfather_counterpart( + keying, + ¤t_graph_positions, + ¤t.graph_positions, + mode, + index, + &position.position.id, + |element| &element.position, + ), &position.position, )?; } @@ -1027,7 +1094,7 @@ fn validate_per_owner_metric_limits( .knob_positions .values() .flatten() - .map(|position| (position.position.id.as_str(), &position.position)) + .map(|position| (position.position.id.as_str(), position)) .collect::>(); for (mode, positions) in &candidate.knob_positions { for (index, position) in positions.iter().enumerate() { @@ -1035,9 +1102,15 @@ fn validate_per_owner_metric_limits( "knobPositions", mode, index, - current_knob_positions - .get(position.position.id.as_str()) - .copied(), + grandfather_counterpart( + keying, + ¤t_knob_positions, + ¤t.knob_positions, + mode, + index, + &position.position.id, + |element| &element.position, + ), &position.position, )?; } @@ -2148,6 +2221,62 @@ mod tests { ); } + #[test] + fn preset_keying_grandfathers_rekeyed_elements_by_mode_and_index() { + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let current = EditorDocumentV1::from_store(&store); + + // 프리셋 로드는 검증 전에 모든 요소의 id를 재발급한다 - 값이 그대로여도 + // id 조회로는 관용 대상을 찾을 수 없다 + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + let error = validate_document_transition(¤t, &candidate, &store, &candidate_store) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("COORDINATE_OUT_OF_RANGE") + ); + + validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ByModeIndex, + ) + .unwrap(); + } + + #[test] + fn preset_keying_still_rejects_newly_raised_metrics() { + let mut store = default_editor_store(); + store.key_positions.get_mut("4key").unwrap()[0].dx = MAX_ABS_COORDINATE + 1.0; + let current = EditorDocumentV1::from_store(&store); + + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + candidate_store.key_positions.get_mut("4key").unwrap()[1].dx = MAX_ABS_COORDINATE + 1.0; + let candidate = EditorDocumentV1::from_store(&candidate_store); + + // (mode,index) 관용은 같은 자리의 기존 위반만 물려받는다 - 멀쩡하던 + // 자리가 새로 초과되면 프리셋 경로에서도 거부한다 + let error = validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ByModeIndex, + ) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("COORDINATE_OUT_OF_RANGE") + ); + } + #[test] fn stage_four_deleted_element_is_excluded_from_per_owner_comparison() { let mut store = default_editor_store(); diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 9cd98922..75d4a80b 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -27,8 +27,9 @@ use super::builtin_sounds::seed_builtin_sounds; use super::editor::{ canonical_request_fingerprint, next_revision, repair_selected_mode, request_fingerprint, request_payload_size, sync_key_counters, touched_pair, validate_document_transition, - validate_history_restore_metadata, validate_paired_update, validate_request_envelope, - RequestFingerprint, MUTATION_ACK_CAPACITY, + validate_document_transition_with_keying, validate_history_restore_metadata, + validate_paired_update, validate_request_envelope, GrandfatherKeying, RequestFingerprint, + MUTATION_ACK_CAPACITY, }; use super::gesture::validate_gesture_commit_request; use super::history::{ @@ -1426,7 +1427,14 @@ impl AppStore { let candidate = EditorDocumentV1::from_store(&scratch); validate_paired_update(¤t, &candidate, true, true)?; scratch.editor_revision = current_store.editor_revision; - validate_document_transition(¤t, &candidate, ¤t_store, &scratch)?; + // 프리셋 스냅샷은 현재 store와 id 세대가 달라 ID 짝짓기가 성립하지 않는다 + validate_document_transition_with_keying( + ¤t, + &candidate, + ¤t_store, + &scratch, + GrandfatherKeying::ByModeIndex, + )?; let changed_fields = current.changed_fields(&candidate); let revision = if changed_fields.is_empty() { @@ -1761,7 +1769,20 @@ impl AppStore { let (keys_touched, key_positions_touched) = touched_pair(touched_fields); validate_paired_update(¤t, &candidate, keys_touched, key_positions_touched)?; - validate_document_transition(¤t, &candidate, ¤t_store, &scratch)?; + // 프리셋 로드는 커밋 직전 모든 요소의 id를 재발급하므로 ID로는 관용 + // 상대를 찾을 수 없다 - 그 트랜잭션만 (모드, index) 짝짓기를 쓴다 + let keying = if history_options.scope == Some(HistoryScope::PresetFull) { + GrandfatherKeying::ByModeIndex + } else { + GrandfatherKeying::ById + }; + validate_document_transition_with_keying( + ¤t, + &candidate, + ¤t_store, + &scratch, + keying, + )?; if changed_fields.contains(&EditorField::Keys) { sync_key_counters(&mut scratch.key_counters, &candidate.keys); From aa601de58258f418af5a4eb744dc3082b3c32c65 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:16:26 +0900 Subject: [PATCH 07/19] =?UTF-8?q?fix:=20=EB=B6=84=EB=A6=AC=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=EC=9D=98=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=20=EB=AA=A8=EB=8D=B8=EC=97=90=EC=84=9C=20plugin=20?= =?UTF-8?q?=ED=96=89=20=EB=88=84=EB=9D=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildLiveLayerModel이 렌더 목록과 달리 창 인지 selector 대신 raw elements를 읽었다. 분리 속성 패널 창은 panelElements만 채우므로, 모든 드롭 커밋의 기준 모델에서 plugin 행이 통째로 빠졌다. plugin 행 드래그는 무음 no-op이 되고 native 드롭은 plugin이 빠진 목록으로 z를 재부여해 기존 plugin과 겹쳤다. 렌더 목록과 같은 selectPropertyPanelPluginElements를 쓴다. Grid와 rotateTargetPluginSessions는 패널 창에 도달하지 않는 경로임을 주석으로 남긴다. Co-Authored-By: Claude Fable 5 --- .../main/Grid/PropertiesPanel/layer/LayerTabContent.tsx | 6 +++++- src/renderer/components/main/Grid/core/Grid.tsx | 1 + src/renderer/plugins/rpc/pluginElementActions.ts | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index 51a22d8f..82fce44e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -239,7 +239,11 @@ const LayerTabContent: React.FC = ({ statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, - pluginElements: usePluginDisplayElementStore.getState().elements, + // 렌더 목록과 같은 창 인지 selector - 분리 패널은 elements가 비어 있어 + // raw로 읽으면 드롭 모델에서 plugin 행이 통째로 빠진다 + pluginElements: selectPropertyPanelPluginElements( + usePluginDisplayElementStore.getState(), + ), }); const groupState = useLayerGroupStore.getState(); const liveDisplayItems = buildDisplayItems({ diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 87bba182..04716ad2 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -710,6 +710,7 @@ const Grid = ({ el.id.length > 0 && !isSyntheticElementId(el.id); + // Grid는 분리 패널 창에 마운트되지 않으므로 elements를 그대로 읽는다 const pluginZIndexesForMode = (): number[] => usePluginDisplayElementStore .getState() diff --git a/src/renderer/plugins/rpc/pluginElementActions.ts b/src/renderer/plugins/rpc/pluginElementActions.ts index 2c031698..19c3ec7b 100644 --- a/src/renderer/plugins/rpc/pluginElementActions.ts +++ b/src/renderer/plugins/rpc/pluginElementActions.ts @@ -24,6 +24,8 @@ export const PLUGIN_RPC_OPERATIONS = { const isPanelWindow = () => window.__dmn_window_type === 'panel'; +// 호출부가 모두 isPanelWindow() 조기 위임 뒤에만 도달하므로 elements를 그대로 +// 읽는다 (패널에서는 panelElements만 채워진다) const rotateTargetPluginSessions = ( fullIds: string[], gestureId?: string, From 33979b3f991c0ee58319647ddc874547c8e2be1e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:18:58 +0900 Subject: [PATCH 08/19] =?UTF-8?q?fix:=20=EB=B0=B1=EC=97=94=EB=93=9C=20ID?= =?UTF-8?q?=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EC=8B=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=9D=B4=20=EB=AC=B4=EC=9D=8C=EC=9C=BC=EB=A1=9C=20=ED=92=80?= =?UTF-8?q?=EB=A6=AC=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit id 조회가 실패하면 곧바로 선택을 제거해, 요소는 그대로인데 신원만 재발급된 경우(탭 프리셋 rekey, v1 어댑터 재발급)에도 사용자의 선택이 사라졌다. base의 조기 반환이 없어져 모든 문서 적용마다 이 판정이 돌기에 더 자주 노출됐다. 해당 타입에 구조 경계가 없으면(길이 불변) 같은 자리의 새 id로 재채택한다. 길이가 바뀐 경우는 기존 경계 판정을 그대로 따른다. 합성 id 판정도 문자열 조립 대신 공유 isSyntheticElementId를 쓴다. Co-Authored-By: Claude Fable 5 --- .../useGridSelectionStore.idReconcile.test.ts | 33 +++++++++++++++++++ .../stores/grid/useGridSelectionStore.ts | 30 ++++++++++++----- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts index ee8bce3f..ce81e55d 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts @@ -59,6 +59,39 @@ describe('id 기반 선택 재조정', () => { expect(selected[0].index).toBe(0); }); + it('백엔드가 id를 재발급해도 같은 자리의 선택을 유지한다', () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: a.id!, index: 1 }]); + // 탭 프리셋 rekey·v1 어댑터 재발급처럼 요소는 그대로인데 신원만 새로 + // 발급되는 경우 - 길이가 그대로면 자리로 재채택한다 + const rekeyedA = { ...a, id: 'rekeyed-a' }; + const rekeyedB = { ...b, id: 'rekeyed-b' }; + + invalidateSelectionForChangedIndexedElementArrays( + arraysOf([b, a]), + arraysOf([rekeyedB, rekeyedA]), + ); + + const selected = useGridSelectionStore.getState().selectedElements; + expect(selected).toHaveLength(1); + expect(selected[0].id).toBe('rekeyed-a'); + expect(selected[0].index).toBe(1); + }); + + it('길이가 바뀌면 재채택 대신 경계 판정을 따른다', () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: a.id!, index: 1 }]); + + invalidateSelectionForChangedIndexedElementArrays( + arraysOf([b, a]), + arraysOf([b]), + ); + + expect(useGridSelectionStore.getState().selectedElements).toHaveLength(0); + }); + it('변화가 없으면 선택 참조를 보존한다', () => { useGridSelectionStore .getState() diff --git a/src/renderer/stores/grid/useGridSelectionStore.ts b/src/renderer/stores/grid/useGridSelectionStore.ts index 88ee5ed3..02897b39 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.ts @@ -5,6 +5,7 @@ import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; import { stableStringify } from '@utils/core/stableStringify'; +import { isSyntheticElementId } from '@src/renderer/editor/model/elementIdMap'; export type SelectableElementType = | 'key' @@ -402,19 +403,32 @@ export function invalidateSelectionForChangedIndexedElementArrays( // 신원 id를 가진 선택은 id로 재조정한다: 살아 있으면 index만 갱신, // 사라졌으면 제거. 재정렬돼도 선택은 같은 요소를 따라간다 - if (element.id !== `${element.type}-${element.index}`) { - const newIndex = nextPositionsFor(element.type).findIndex( + if (!isSyntheticElementId(element.id)) { + const nextPositions = nextPositionsFor(element.type); + const newIndex = nextPositions.findIndex( (position) => position?.id === element.id, ); - if (newIndex === -1) { - changed = true; - return []; + if (newIndex !== -1) { + if (newIndex !== element.index) { + changed = true; + return [{ ...element, index: newIndex }]; + } + return [element]; } - if (newIndex !== element.index) { + + // id가 사라졌어도 그 타입에 구조 경계가 없으면 요소는 그대로이고 신원만 + // 재발급된 것이다 (탭 프리셋 rekey, v1 어댑터 재발급) - 자리로 재채택한다 + const rekeyed = + boundaries.get(element.type) === undefined && + typeof element.index === 'number' + ? nextPositions[element.index]?.id + : undefined; + if (rekeyed !== undefined) { changed = true; - return [{ ...element, index: newIndex }]; + return [{ ...element, id: rekeyed }]; } - return [element]; + changed = true; + return []; } // 합성 id 폴백 (backfill 전 데이터): 기존 경계 휴리스틱 유지 From 6132db2575a4dab1b3dac0fe22ad6e56ff63911e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:22:18 +0900 Subject: [PATCH 09/19] =?UTF-8?q?fix:=20=EB=B6=99=EC=97=AC=EB=84=A3?= =?UTF-8?q?=EA=B8=B0=20=EC=84=A0=ED=83=9D=EC=9D=84=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EC=A0=84=20=EB=8F=99=EA=B8=B0=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 선택 이동이 커밋 라운드트립 뒤 성공 게이트로 밀려나, 사본이 이미 화면에 보이는데도 선택은 원본을 가리켰다. 그 사이 Delete를 누르면 사본이 아니라 원본이 지워지고, 커밋이 편입 후 실패하면 선택이 영영 옮겨가지 않았다. eager 적용 직후 동기 구간에서 동결 id로 선택을 옮긴다. 실패로 eager가 롤백되면 다음 문서 적용의 선택 재조정이 정리한다. Co-Authored-By: Claude Fable 5 --- .../hooks/Grid/useGridSelection.test.tsx | 41 +++++++ src/renderer/hooks/Grid/useGridSelection.ts | 116 +++++++++--------- 2 files changed, 99 insertions(+), 58 deletions(-) diff --git a/src/renderer/hooks/Grid/useGridSelection.test.tsx b/src/renderer/hooks/Grid/useGridSelection.test.tsx index 31fc6994..c26fdd55 100644 --- a/src/renderer/hooks/Grid/useGridSelection.test.tsx +++ b/src/renderer/hooks/Grid/useGridSelection.test.tsx @@ -280,6 +280,47 @@ describe('useGridSelection compound history gesture', () => { expect(mocks.commitPatch).not.toHaveBeenCalled(); }); + it('붙여넣기 선택은 커밋 정산을 기다리지 않고 옮겨간다', async () => { + act(() => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: STABLE_KEY_ID, index: 0 }]); + useGridSelectionStore + .getState() + .setClipboard([ + { type: 'key', keyCode: 'KeyB', position: keyPosition }, + ]); + }); + // 커밋 정산을 붙잡아 라운드트립 중 상태를 관찰한다 + let settle: (value: { committed: boolean; satisfied: boolean }) => void = + () => {}; + mocks.runMixedGestureIntent.mockImplementationOnce( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + + let pasting: Promise; + await act(async () => { + pasting = api.pasteElements(); + await Promise.resolve(); + }); + + // 정산 전에 이미 사본을 가리켜야 한다 - 원본에 남아 있으면 라운드트립 + // 중의 Delete가 원본을 지운다 + const duringRoundTrip = + useGridSelectionStore.getState().selectedElements; + expect(duringRoundTrip).toHaveLength(1); + expect(duringRoundTrip[0].id).not.toBe(STABLE_KEY_ID); + expect(duringRoundTrip[0].index).toBe(1); + + await act(async () => { + settle({ committed: true, satisfied: true }); + await pasting; + }); + }); + it('혼합 붙여넣기 중 동기 예외가 나도 staged transaction을 정산한다', async () => { act(() => { useGridSelectionStore.getState().setClipboard([ diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 15c8b6d3..15bc509c 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -1466,6 +1466,64 @@ export function useGridSelection({ } } + // 선택 이동은 eager 직후 동기 구간에서 - await 뒤로 미루면 라운드트립 + // 동안 선택이 원본에 남아 Delete 같은 후속 조작이 원본을 지운다. + // 실패로 eager가 롤백되면 다음 문서 적용의 선택 재조정이 정리한다 + const newSelectedElements: SelectedElement[] = []; + const collect = ( + type: 'key' | 'stat' | 'graph' | 'knob', + record: Record>, + ids: readonly string[], + ) => { + const list = record[selectedKeyType] ?? []; + for (const id of ids) { + const index = list.findIndex((position) => position.id === id); + if (index !== -1) { + newSelectedElements.push({ type, id, index }); + } + } + }; + collect( + 'key', + useKeyStore.getState().canonicalPositions as never, + keysToAdd.map((entry) => entry.position.id!), + ); + collect( + 'stat', + useStatItemStore.getState().positions as never, + statsToAdd.map((entry) => entry.position.id!), + ); + collect( + 'graph', + useGraphItemStore.getState().positions as never, + graphsToAdd.map((entry) => entry.position.id!), + ); + collect( + 'knob', + useKnobItemStore.getState().positions as never, + knobsToAdd.map((entry) => entry.position.id!), + ); + const presentPluginIds = new Set( + usePluginDisplayElementStore + .getState() + .elements.map((element) => element.fullId), + ); + for (const element of frozenPluginElements) { + if (presentPluginIds.has(element.fullId)) { + newSelectedElements.push({ type: 'plugin', id: element.fullId }); + } + } + if (newSelectedElements.length > 0) { + useGridSelectionStore.getState().setSkipPanelModeSwitch(true); + if (groupIdMap.size > 0) { + useGridSelectionStore + .getState() + .setFullSelection(newSelectedElements, [...groupIdMap.values()]); + } else { + setSelectedElements(newSelectedElements); + } + } + let result: { committed: boolean; satisfied: boolean }; try { result = await runMixedGestureElementIntent({ @@ -1516,64 +1574,6 @@ export function useGridSelection({ result = { committed: false, satisfied: false }; } - if (result.committed || result.satisfied) { - // 선택 이동은 성공 후 - eager 유지 대신 단순화(수렴 결정) - const newSelectedElements: SelectedElement[] = []; - const collect = ( - type: 'key' | 'stat' | 'graph' | 'knob', - record: Record>, - ids: readonly string[], - ) => { - const list = record[selectedKeyType] ?? []; - for (const id of ids) { - const index = list.findIndex((position) => position.id === id); - if (index !== -1) { - newSelectedElements.push({ type, id, index }); - } - } - }; - collect( - 'key', - useKeyStore.getState().canonicalPositions as never, - keysToAdd.map((entry) => entry.position.id!), - ); - collect( - 'stat', - useStatItemStore.getState().positions as never, - statsToAdd.map((entry) => entry.position.id!), - ); - collect( - 'graph', - useGraphItemStore.getState().positions as never, - graphsToAdd.map((entry) => entry.position.id!), - ); - collect( - 'knob', - useKnobItemStore.getState().positions as never, - knobsToAdd.map((entry) => entry.position.id!), - ); - const presentPluginIds = new Set( - usePluginDisplayElementStore - .getState() - .elements.map((element) => element.fullId), - ); - for (const element of frozenPluginElements) { - if (presentPluginIds.has(element.fullId)) { - newSelectedElements.push({ type: 'plugin', id: element.fullId }); - } - } - if (newSelectedElements.length > 0) { - useGridSelectionStore.getState().setSkipPanelModeSwitch(true); - if (groupIdMap.size > 0) { - useGridSelectionStore - .getState() - .setFullSelection(newSelectedElements, [...groupIdMap.values()]); - } else { - setSelectedElements(newSelectedElements); - } - } - } - sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { elements: usePluginDisplayElementStore.getState().elements, }); From ba8be9f23c4f8ec8046b2fd194b103b7bc0bc742 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:24:32 +0900 Subject: [PATCH 10/19] =?UTF-8?q?fix:=20=EB=B6=99=EC=97=AC=EB=84=A3?= =?UTF-8?q?=EA=B8=B0=EA=B0=80=20=EB=B3=B5=EC=82=AC=EB=B3=B8=EC=9D=98=20?= =?UTF-8?q?=EC=83=81=EB=8C=80=20=EC=8A=A4=ED=83=9D=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=92=A4=EC=A7=91=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 붙여넣기 블록이 keys→stats→graphs→knobs→plugins의 타입별 payload 순서로 조립되는데 배열 위치가 곧 최종 z라, 원본에서 stat이 key 위에 있어도 붙여넣으면 key가 위로 올라갔다. 마퀴·범위 선택은 배열 인덱스 순으로 담기 때문에 같은 타입 안에서도 뒤집혔다. 블록 내부를 동결 zIndex 내림차순으로 정렬하고 동률은 payload 순서를 유지한다. Co-Authored-By: Claude Fable 5 --- src/renderer/hooks/Grid/useGridSelection.ts | 24 +++++++------ src/renderer/utils/layerGroupUtils.test.ts | 40 +++++++++++++++++++++ src/renderer/utils/layerGroupUtils.ts | 13 +++++++ 3 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 src/renderer/utils/layerGroupUtils.test.ts diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 15bc509c..342debb3 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -34,6 +34,7 @@ import { buildNextLayerGroupName, buildLayerItemsForMode, applyZIndexToLayerOrder, + orderPastedItemsByFrozenZ, } from '@utils/layerGroupUtils'; import { commitSelectedGeometryByIds } from '@src/renderer/editor/runtime/elementOps'; import { @@ -1295,16 +1296,19 @@ export function useGridSelection({ .filter((item) => newIdSet.has(item.id)) .map((item) => [item.id, item]), ); - // 붙여넣기 블록 내부 순서는 동결 payload 순서 유지 - const pastedOrdered = [ - ...keysToAdd.map((entry) => entry.position.id!), - ...statsToAdd.map((entry) => entry.position.id!), - ...graphsToAdd.map((entry) => entry.position.id!), - ...knobsToAdd.map((entry) => entry.position.id!), - ...frozenPluginElements.map((element) => element.fullId), - ] - .map((id) => pastedById.get(id)) - .filter((item): item is NonNullable => Boolean(item)); + // 블록 내부는 원본의 상대 스택을 따른다 - payload는 타입별로 묶인 + // 순서라 그대로 쓰면 복사본의 위아래가 뒤집힌다 + const pastedOrdered = orderPastedItemsByFrozenZ( + [ + ...keysToAdd.map((entry) => entry.position.id!), + ...statsToAdd.map((entry) => entry.position.id!), + ...graphsToAdd.map((entry) => entry.position.id!), + ...knobsToAdd.map((entry) => entry.position.id!), + ...frozenPluginElements.map((element) => element.fullId), + ] + .map((id) => pastedById.get(id)) + .filter((item): item is NonNullable => Boolean(item)), + ); let anchorIndex = 0; if (frozenAnchor?.groupId) { const index = existingItems.findIndex( diff --git a/src/renderer/utils/layerGroupUtils.test.ts b/src/renderer/utils/layerGroupUtils.test.ts new file mode 100644 index 00000000..602a45c0 --- /dev/null +++ b/src/renderer/utils/layerGroupUtils.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { orderPastedItemsByFrozenZ } from './layerGroupUtils'; + +describe('orderPastedItemsByFrozenZ', () => { + it('원본 스택 순서를 따른다 (payload 타입 순서가 아니라)', () => { + // 마퀴 선택은 key를 먼저 담으므로 payload는 [key, stat]이지만 + // 원본에서는 stat이 위에 있다 + const ordered = orderPastedItemsByFrozenZ([ + { id: 'key', zIndex: 1 }, + { id: 'stat', zIndex: 5 }, + ]); + + expect(ordered.map((item) => item.id)).toEqual(['stat', 'key']); + }); + + it('같은 타입 안에서도 z 내림차순을 따른다', () => { + const ordered = orderPastedItemsByFrozenZ([ + { id: 'a', zIndex: 0 }, + { id: 'b', zIndex: 9 }, + { id: 'c', zIndex: 4 }, + ]); + + expect(ordered.map((item) => item.id)).toEqual(['b', 'c', 'a']); + }); + + it('z가 같으면 payload 순서를 유지한다', () => { + const ordered = orderPastedItemsByFrozenZ([ + { id: 'first', zIndex: 3 }, + { id: 'second', zIndex: 3 }, + { id: 'third', zIndex: 3 }, + ]); + + expect(ordered.map((item) => item.id)).toEqual([ + 'first', + 'second', + 'third', + ]); + }); +}); diff --git a/src/renderer/utils/layerGroupUtils.ts b/src/renderer/utils/layerGroupUtils.ts index 36d9c608..934d325d 100644 --- a/src/renderer/utils/layerGroupUtils.ts +++ b/src/renderer/utils/layerGroupUtils.ts @@ -413,6 +413,19 @@ export function buildLayerItemsForMode( return items; } +/** + * 붙여넣기 블록의 스택 순서 결정. 배열 위치가 곧 최종 z이므로 원본의 상대 + * 스택(zIndex 내림차순)을 따르고, 동률은 payload 순서로 안정 정렬한다 + */ +export function orderPastedItemsByFrozenZ( + items: readonly T[], +): T[] { + return items + .map((item, order) => ({ item, order })) + .sort((a, b) => b.item.zIndex - a.item.zIndex || a.order - b.order) + .map((entry) => entry.item); +} + /** 선택 상태 기반으로 paste 앵커 위치 결정 (선택된 레이어 바로 위에 삽입) */ export function findPasteAnchorIndex( orderedItems: LayerItemForOrder[], From ef225799fde75b9d8b82568f781a087024d93d92 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:25:12 +0900 Subject: [PATCH 11/19] =?UTF-8?q?fix:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EC=9D=98=20=EA=B7=B8=EB=A3=B9=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20live=20=EB=AA=A8=EB=8D=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=9C=A0=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아이템 드래그 드롭이 index는 live 모델로 재해석하면서 targetGroupId만 mousemove 캡처값을 그대로 넘겼다. 앵커 행이 그 사이 그룹을 떠나도 가드는 그룹 생존만 보므로, 드롭한 요소가 사용자가 넣은 적 없는 그룹에 편입되고 드롭 위치보다 위로 튀어 올랐다. 그룹 드래그 경로와 같이 resolveItemDropTarget으로 재유도한다. Co-Authored-By: Claude Fable 5 --- .../main/Grid/PropertiesPanel/layer/useLayerDnD.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts index 6c3f1b7b..42d65a1e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts @@ -970,8 +970,15 @@ export function useLayerDnD({ liveModel.displayItems, ); if (resolvedIndex != null) { + // 그룹도 live 모델로 재유도 - 캡처 시점 값을 그대로 쓰면 앵커가 + // 그 사이 그룹을 떠났을 때 사용자가 넣지 않은 그룹에 편입된다 + const dropTarget = resolveItemDropTarget( + resolvedIndex, + new Set(draggedIds), + liveModel, + ); performMultiDrop(draggedIds, resolvedIndex, { - targetGroupId: target.targetGroupId, + targetGroupId: dropTarget.targetGroupId, liveModel, }); } From bf6b36720b6316d5ab8d263aa2cf58198323e565 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:30:20 +0900 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EC=9D=98=20plugin=20z=20=EC=93=B0=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20fail-closed=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EB=92=A4=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commitDropIntents가 영속되는 plugin z authority 쓰기를 eager 게이트보다 먼저 무조건 실행했다. 동시 변경으로 게이트가 닫히면 native 의도는 전부 드롭되는데 plugin만 새 깊이로 옮겨진 반쪽 순서가 저장됐다. plugin-only 드롭은 그대로 즉시 쓰고, native 혼합은 게이트를 통과한 뒤에 쓴다. 편입 후 실패·스킵에서는 드래그 전 z로 되돌린다. Co-Authored-By: Claude Fable 5 --- .../layer/useLayerDnD.routing.test.tsx | 45 ++++++++++++++++++- .../Grid/PropertiesPanel/layer/useLayerDnD.ts | 44 +++++++++++++++--- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx index 96855bc0..2b51fdef 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ })), captureBaseline: vi.fn(() => null), reportElementOpError: vi.fn(), + reportElementOpSkipped: vi.fn(), setPluginZIndexes: vi.fn(), commitPatch: vi.fn(() => Promise.resolve()), setKeyPositions: vi.fn(), @@ -34,7 +35,7 @@ vi.mock('@src/renderer/editor/runtime/elementIntent', () => ({ intentPatch: (patch: unknown) => patch === null ? { kind: 'targetLost' } : { kind: 'patch', patch }, reportElementOpError: mocks.reportElementOpError, - reportElementOpSkipped: vi.fn(), + reportElementOpSkipped: mocks.reportElementOpSkipped, })); vi.mock('@plugins/rpc/pluginElementActions', () => ({ @@ -283,6 +284,48 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { expect(byId.get(ID_A)).toMatchObject({ zIndex: 1 }); }); + it('eager 게이트가 닫히면 plugin z도 쓰지 않는다', async () => { + mocks.reportElementOpSkipped.mockClear(); + mocks.applyGestureEagerly.mockImplementationOnce(() => ({ + matched: false, + receipt: null, + })); + const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; + const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + await dragItemToEnd(startItems, { + layerItems: liveItems, + displayItems: toDisplay(liveItems), + }); + + // native가 하나도 적용되지 않았으므로 plugin만 옮겨진 반쪽 순서가 + // 영속되면 안 된다 + expect(mocks.setPluginZIndexes).not.toHaveBeenCalled(); + expect(mocks.runElementIntent).not.toHaveBeenCalled(); + expect(mocks.reportElementOpSkipped).toHaveBeenCalledTimes(1); + }); + + it('편입 후 실패는 plugin z를 이전 값으로 되돌린다', async () => { + mocks.runElementIntent.mockImplementation((options: unknown) => { + (options as { applyEager: () => unknown }).applyEager(); + return Promise.reject(new Error('start failed')); + }); + const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; + const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + await dragItemToEnd(startItems, { + layerItems: liveItems, + displayItems: toDisplay(liveItems), + }); + await act(async () => { + await Promise.resolve(); + }); + + // 쓰기 1회 + 복원 1회, 복원은 드래그 전 z로 되돌린다 + expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(2); + expect(mocks.setPluginZIndexes.mock.calls[1][0]).toEqual([ + { fullId: 'plugin-x:one', zIndex: 0 }, + ]); + }); + it('native 전용 편입 전 실패는 runner가 소유하고 layerGroups는 eager를 건드리지 않는다', async () => { mocks.runElementIntent.mockImplementation((options: unknown) => { // 러너 계약 재현: eager 적용 후 편입 전 실패 diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts index 42d65a1e..0fc1371c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts @@ -428,7 +428,12 @@ export function useLayerDnD({ 'key' | 'stat' | 'graph' | 'knob', Map> >; - pluginZIndexUpdates: Array<{ fullId: string; zIndex: number }>; + // beforeZIndex는 편입 후 실패 시 CAS 복원용 + pluginZIndexUpdates: Array<{ + fullId: string; + zIndex: number; + beforeZIndex: number; + }>; } // 새 표시 순서를 의도 집합으로 변환 - 안정 id는 id 의도, 합성은 시작 @@ -446,7 +451,11 @@ export function useLayerDnD({ newItems.forEach((item, idx) => { const newZIndex = maxZIndex - idx; if (item.type === 'plugin') { - sets.pluginZIndexUpdates.push({ fullId: item.id, zIndex: newZIndex }); + sets.pluginZIndexUpdates.push({ + fullId: item.id, + zIndex: newZIndex, + beforeZIndex: item.zIndex, + }); return; } const intent: Record = { @@ -471,12 +480,13 @@ export function useLayerDnD({ // 호출 시점 full-record는 대기 중 정산된 격리 plugin 쓰기를 되돌린다. // plugin z-index는 별도 authority 쓰기로 editor 커밋과 비원자(기존 의미론) const commitDropIntents = (sets: DropIntentSets, skipContext: string) => { - if (sets.pluginZIndexUpdates.length > 0) { - setPluginElementZIndexes(sets.pluginZIndexUpdates); - } + const hasPluginZ = sets.pluginZIndexUpdates.length > 0; const hasNativeIntent = sets.nativeIntents.size > 0 || sets.syntheticIndexIntents.size > 0; - if (!hasNativeIntent) return; + if (!hasNativeIntent) { + if (hasPluginZ) setPluginElementZIndexes(sets.pluginZIndexUpdates); + return; + } const baseline = dndBaselineRef.current; const hasSynthetic = sets.syntheticIndexIntents.size > 0; // 결합 eager 단일 소유 - preflight 게이트, 양쪽 적용, 최종 봉인이 @@ -487,9 +497,25 @@ export function useLayerDnD({ propertyIntents: sets.nativeIntents, }); if (!eager.matched) { + // native가 하나도 적용되지 않았으므로 plugin 쓰기도 하지 않는다 - + // 게이트보다 먼저 쓰면 반쪽 순서가 그대로 영속된다 reportElementOpSkipped(skipContext); return; } + // plugin z는 별도 authority 쓰기라 editor 커밋과 비원자다(기존 의미론). + // 편입 후 실패는 되돌려 반쪽 순서가 남지 않게 한다 + if (hasPluginZ) { + setPluginElementZIndexes(sets.pluginZIndexUpdates); + } + const restorePluginZ = () => { + if (!hasPluginZ) return; + setPluginElementZIndexes( + sets.pluginZIndexUpdates.map((update) => ({ + fullId: update.fullId, + zIndex: update.beforeZIndex, + })), + ); + }; void runElementIntent({ applyEager: () => eager.receipt, generate: (base) => { @@ -546,10 +572,14 @@ export function useLayerDnD({ }) .then((result) => { if (!result.committed && !result.satisfied) { + restorePluginZ(); reportElementOpSkipped(skipContext); } }) - .catch(reportElementOpError); + .catch((error) => { + restorePluginZ(); + reportElementOpError(error); + }); }; const performMultiDrop = async ( From e054828cc4c0b8f5877dfb670faca5a30e4afe47 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:44:41 +0900 Subject: [PATCH 13/19] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/renderer/hooks/Grid/useGridSelection.test.tsx | 9 +++++---- src/renderer/hooks/Grid/useGridSelection.ts | 4 +--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/renderer/hooks/Grid/useGridSelection.test.tsx b/src/renderer/hooks/Grid/useGridSelection.test.tsx index c26fdd55..88b66b4e 100644 --- a/src/renderer/hooks/Grid/useGridSelection.test.tsx +++ b/src/renderer/hooks/Grid/useGridSelection.test.tsx @@ -292,8 +292,10 @@ describe('useGridSelection compound history gesture', () => { ]); }); // 커밋 정산을 붙잡아 라운드트립 중 상태를 관찰한다 - let settle: (value: { committed: boolean; satisfied: boolean }) => void = - () => {}; + let settle: (value: { + committed: boolean; + satisfied: boolean; + }) => void = () => {}; mocks.runMixedGestureIntent.mockImplementationOnce( () => new Promise((resolve) => { @@ -309,8 +311,7 @@ describe('useGridSelection compound history gesture', () => { // 정산 전에 이미 사본을 가리켜야 한다 - 원본에 남아 있으면 라운드트립 // 중의 Delete가 원본을 지운다 - const duringRoundTrip = - useGridSelectionStore.getState().selectedElements; + const duringRoundTrip = useGridSelectionStore.getState().selectedElements; expect(duringRoundTrip).toHaveLength(1); expect(duringRoundTrip[0].id).not.toBe(STABLE_KEY_ID); expect(duringRoundTrip[0].index).toBe(1); diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 342debb3..b6039a7b 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -1528,9 +1528,8 @@ export function useGridSelection({ } } - let result: { committed: boolean; satisfied: boolean }; try { - result = await runMixedGestureElementIntent({ + await runMixedGestureElementIntent({ gestureId, initialPluginIds: pluginScope( usePluginDisplayElementStore.getState().elements, @@ -1575,7 +1574,6 @@ export function useGridSelection({ // 편입 후 실패의 상태 정합은 projection·canonical pull이 소유 - // 호출부 경계에서는 기록만 (삭제 경로와 대칭) console.error('Failed to persist pasted elements', error); - result = { committed: false, satisfied: false }; } sendBridgeMessageBestEffort('overlay', 'plugin:displayElements:sync', { From 6a029978a6dca3557457d33fb4f5999cef64d430 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 16:45:34 +0900 Subject: [PATCH 14/19] =?UTF-8?q?docs:=20v1=20=EC=93=B0=EA=B8=B0=EC=9D=98?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20id=20=EC=B2=98=EB=A6=AC=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중복 id가 거부에서 사본 재발급으로 바뀐 것을 en/ko 양쪽에 기술한다. Co-Authored-By: Claude Fable 5 --- docs/content/en/api-reference/editor/page.mdx | 6 ++++-- docs/content/ko/api-reference/editor/page.mdx | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index 46c23c3a..0be32660 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -56,8 +56,10 @@ that top-level collection. It is not an item-level diff. Every element in the position collections (`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`) carries a stable `id` (UUID) assigned and owned by the app. Treat it as opaque: echo back the value you read and never - invent or duplicate one. A written element with a missing or unknown `id` - gets a fresh one from the backend. See the + invent one. A written element with a missing or unknown `id` gets a fresh one + from the backend. If the same `id` appears more than once — for example when + you copy an element you read and append it — the element in its original slot + keeps the identity and the remaining copies get fresh ones. See the [Keys API](/docs/api-reference/keys) for the identity rules. diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index 692fb1e1..44df6ec5 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -55,9 +55,10 @@ type EditorPatchV1 = { 위치 컬렉션(`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`)의 모든 요소는 앱이 발급·소유하는 안정 `id`(UUID)를 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, 직접 - 만들거나 중복시키지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 - 값을 발급합니다. 신원 규칙은 [Keys API](/docs/api-reference/keys)를 - 참고하세요. + 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 값을 + 발급합니다. 읽은 요소를 복사해 배열에 덧붙이는 경우처럼 같은 `id`가 여러 번 + 나오면, 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 받습니다. + 신원 규칙은 [Keys API](/docs/api-reference/keys)를 참고하세요. ## 현재 문서 조회 From b482c1324bb0e5fd1cf39ea640b6f8691c307923 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 17:03:59 +0900 Subject: [PATCH 15/19] =?UTF-8?q?fix:=20index=20=EC=8A=B9=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=ED=98=95=ED=83=9C=20=EA=B3=A0=EC=A0=95=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A1=9C=20=ED=95=9C=EC=A0=95=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=8A=AC=EB=A1=AF=20=EC=9B=A8=EC=9D=B4=EB=B8=8C=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=92=EB=B3=B4=EB=8B=A4=20=EC=95=9E=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index 승계를 모든 컬렉션에 적용하면 형태 제약이 없는 stat/graph/knob에서 같은 길이의 삭제+추가가 새 요소에 삭제된 요소의 신원을 넘긴다. 신원 기준을 ID로 옮긴 목적(새 요소가 남의 관용을 물려받지 않는 것)과 정면으로 어긋난다. index 승계를 keys 미동반 keyPositions 패치로 한정한다 - 그 경로만 validate_paired_update가 형태 불변을 보장한다. 또한 슬롯 전용 웨이브를 값 재바인딩 웨이브보다 앞에 둔다. 슬롯 고정이 우연한 값 일치보다 강한 신원 단서이고, 그래야 두 키의 좌표 맞바꿈이 keys 동반 여부와 무관하게 같은 결과를 낸다. Co-Authored-By: Claude Fable 5 --- src-tauri/src/state/native_element_id.rs | 121 ++++++++++++++++++++--- 1 file changed, 105 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 75747e45..0c18d5aa 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -614,11 +614,14 @@ fn inherit_ids_by_value( } } -// 길이가 같은 모드는 index가 신원이다(v1 전체 레코드 쓰기의 master 의미론). -// 값이 바뀐 요소도 제자리 ID를 지켜야 재발급이 신원 회전으로 번지지 않는다 -fn inherit_ids_by_index( - current: &HashMap>, - candidate: &mut HashMap>, +// keys를 동반하지 않는 keyPositions 패치에서만 쓴다. 그 경우 candidate는 +// validate_paired_update가 형태 불변을 강제하므로 같은 길이 = 같은 슬롯 집합의 +// 제자리 편집이고, index가 곧 신원이다(master 의미론). 형태 제약이 없는 +// stat/graph/knob에 쓰면 같은 길이의 삭제+추가에서 새 요소가 삭제된 요소의 +// 신원을 물려받는다 +fn inherit_ids_by_index( + current: &HashMap>, + candidate: &mut HashMap>, consumed_current_ids: &mut HashSet, ) { for mode in sorted_modes(candidate) { @@ -632,15 +635,15 @@ fn inherit_ids_by_index( continue; } for (element, current_element) in elements.iter_mut().zip(current_elements) { - if !element.position().id.is_empty() { + if !element.id.is_empty() { continue; } - let id = current_element.position().id.clone(); + let id = current_element.id.clone(); if id.is_empty() || consumed_current_ids.contains(&id) { continue; } consumed_current_ids.insert(id.clone()); - element.position_mut().id = id; + element.id = id; } } } @@ -653,8 +656,8 @@ fn adapt_v1_collection( reserved: &mut HashSet, ) { keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); - // 승계 순서: 같은 모드 index(길이 동일) → 같은 모드 값 → 전역 값 폴백 - inherit_ids_by_index(current, candidate, consumed_current_ids); + // 승계 순서: 같은 모드 값 → 전역 값 폴백. index 승계는 형태가 고정된 + // keyPositions 단독 패치 전용이라 여기서는 쓰지 않는다 inherit_ids_by_value_within_mode(current, candidate, consumed_current_ids); inherit_ids_by_value( &ordered_current_elements(current), @@ -782,10 +785,14 @@ fn adapt_v1_key_position_ids( reserved: &mut HashSet, ) { let Some(patch_keys) = patch_keys else { - adapt_v1_collection( - &store.key_positions, + // keys 미동반 패치는 형태가 고정된다 - index가 신원이므로 값 승계보다 + // 먼저 제자리 id를 확정해 값 편집이 신원 회전으로 번지지 않게 한다 + keep_or_rekey_supplied_ids(candidate, canonical_ids, consumed_current_ids, reserved); + inherit_ids_by_index(&store.key_positions, candidate, consumed_current_ids); + inherit_ids_by_value_within_mode(&store.key_positions, candidate, consumed_current_ids); + inherit_ids_by_value( + &ordered_current_elements(&store.key_positions), candidate, - canonical_ids, consumed_current_ids, reserved, ); @@ -796,9 +803,12 @@ fn adapt_v1_key_position_ids( let current_pairs = slot_paired_current_positions(&store.keys, &store.key_positions); // 웨이브 순서: 같은 모드 슬롯+값 → 모드 간 슬롯+값(모드 이동) → - // 같은 모드 값(재바인딩) → 같은 모드 index 슬롯(값 변경 이동) → - // 마지막 전역 값 폴백과 신규 발급 - for (match_slot, same_mode_only) in [(true, true), (true, false), (false, true)] { + // 같은 모드 index 슬롯(값 변경 이동) → 같은 모드 값(재바인딩) → + // 마지막 전역 값 폴백과 신규 발급. + // 슬롯 고정이 우연한 값 일치보다 강한 신원 단서라 값 웨이브보다 앞선다 - + // 그래야 두 키의 좌표를 맞바꾸는 패치가 keys 동반 여부와 무관하게 같은 + // 결과(제자리 값 편집)를 낸다 + for (match_slot, same_mode_only) in [(true, true), (true, false)] { consume_slot_paired_ids( candidate, patch_keys, @@ -809,6 +819,14 @@ fn adapt_v1_key_position_ids( ); } consume_slot_only_ids(candidate, patch_keys, ¤t_pairs, consumed_current_ids); + consume_slot_paired_ids( + candidate, + patch_keys, + ¤t_pairs, + consumed_current_ids, + false, + true, + ); inherit_ids_by_value_within_mode(&store.key_positions, candidate, consumed_current_ids); inherit_ids_by_value( @@ -971,6 +989,13 @@ mod tests { } } + fn stat_position(stat_type: StatType, dx: f64) -> StatPosition { + StatPosition { + stat_type, + position: position(dx), + } + } + fn store_with_all_collections() -> AppStoreData { let mut store = AppStoreData { key_positions: HashMap::from([( @@ -1302,6 +1327,70 @@ mod tests { assert_eq!(positions[1].id, original[1]); } + #[test] + fn v1_idless_equal_length_delete_and_append_does_not_reuse_the_deleted_id() { + let mut store = AppStoreData { + stat_positions: HashMap::from([( + "mode".to_string(), + vec![ + stat_position(StatType::Kps, 1.0), + stat_position(StatType::Kps, 2.0), + stat_position(StatType::Kps, 3.0), + ], + )]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + let ids = store.stat_positions["mode"] + .iter() + .map(|element| element.position.id.clone()) + .collect::>(); + // 가운데를 지우고 새 요소를 덧붙인다 - 길이는 그대로다 + let mut patch = EditorPatchV1 { + stat_positions: Some(HashMap::from([( + "mode".to_string(), + vec![ + stat_position(StatType::Kps, 1.0), + stat_position(StatType::Kps, 3.0), + stat_position(StatType::Kps, 9.0), + ], + )])), + ..EditorPatchV1::default() + }; + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 살아남은 요소는 값으로 자기 신원을 지키고, 신규 요소는 삭제된 + // 요소의 신원을 물려받지 않는다 + let positions = &patch.stat_positions.unwrap()["mode"]; + assert_eq!(positions[0].position.id, ids[0]); + assert_eq!(positions[1].position.id, ids[2]); + assert!(!ids.contains(&positions[2].position.id)); + assert!(is_valid_element_id(&positions[2].position.id)); + } + + #[test] + fn v1_paired_value_swap_keeps_ids_with_their_slots() { + let store = keyed_store( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(1.0), position(2.0)], + ); + let id_a = store.key_positions["mode"][0].id.clone(); + let id_b = store.key_positions["mode"][1].id.clone(); + let mut patch = paired_patch( + vec![KeySlot::from("A"), KeySlot::from("B")], + vec![position(2.0), position(1.0)], + ); + + prepare_commit_patch_element_ids(&store, &mut patch).unwrap(); + + // 슬롯이 그대로면 좌표 맞바꿈은 제자리 값 편집이다 - keys 미동반 + // 패치와 같은 결과를 낸다 + let positions = &patch.key_positions.unwrap()["mode"]; + assert_eq!(positions[0].id, id_a); + assert_eq!(positions[1].id, id_b); + } + #[test] fn v1_idless_patch_prefers_same_mode_and_never_steals_across_modes() { let mut store = AppStoreData { From 0eb79045c19d933fb8ba8efd13e6fd74d27651f1 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 17:14:55 +0900 Subject: [PATCH 16/19] =?UTF-8?q?fix:=20=EC=A0=91=ED=9E=8C=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EB=93=9C=EB=A1=AD=20=EC=9D=98=EB=8F=84=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=EA=B3=BC=20plugin=20z=20=EB=B3=B5=EC=9B=90=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 지적 반영. targetGroupId 재유도가 접힌 그룹 헤더 하단 드롭까지 덮어써, 그룹 편입이라는 명시적 사용자 의도가 사라졌다(접힌 그룹은 자식 행을 렌더하지 않으므로 재유도는 항상 undefined를 낸다). 헤더 앵커가 있는 경로는 재유도에서 제외한다 - 헤더 생존은 앵커 해석이 이미 확인한다. plugin z 복원도 두 가지를 고친다. 복원 전 현재 값을 확인해 우리가 쓴 값일 때만 되돌리고(native 속성 receipt와 같은 CAS 규칙), 러너가 실제로 receipt를 되돌린 경우에만 복원한다. 편입 후 transient 실패는 pendingLocal 재시도가 소유하므로 되돌리면 native만 새 순서로 남는 반쪽 상태가 된다. Co-Authored-By: Claude Fable 5 --- .../layer/useLayerDnD.routing.test.tsx | 116 +++++++++++++++++- .../Grid/PropertiesPanel/layer/useLayerDnD.ts | 52 +++++--- src/renderer/editor/runtime/elementIntent.ts | 11 +- 3 files changed, 156 insertions(+), 23 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx index 2b51fdef..da9c1fc7 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.routing.test.tsx @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ setLayerGroups: vi.fn(), selectedElements: [] as Array<{ id: string }>, selectedGroupIds: [] as string[], + pluginElements: [] as Array<{ fullId: string; zIndex: number }>, })); vi.mock('@src/renderer/editor/runtime/elementIntent', () => ({ @@ -49,6 +50,15 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ }, })); +vi.mock('@stores/plugin/usePluginDisplayElementStore', () => ({ + usePluginDisplayElementStore: { + getState: () => ({ elements: mocks.pluginElements }), + }, + selectPropertyPanelPluginElements: (state: { + elements: Array<{ fullId: string; zIndex: number }>; + }) => state.elements, +})); + vi.mock('@stores/data/useKeyStore', () => ({ useKeyStore: { getState: () => ({ @@ -218,6 +228,8 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { mocks.setLayerGroups.mockClear(); mocks.selectedElements = []; mocks.selectedGroupIds = []; + mocks.pluginElements = []; + mocks.reportElementOpSkipped.mockClear(); }); afterEach(async () => { @@ -304,13 +316,26 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { expect(mocks.reportElementOpSkipped).toHaveBeenCalledTimes(1); }); - it('편입 후 실패는 plugin z를 이전 값으로 되돌린다', async () => { + it('native receipt가 되돌아가면 plugin z도 이전 값으로 되돌린다', async () => { + // 러너 계약: 편입 전 실패는 receipt를 되돌리고 onRolledBack을 부른다 mocks.runElementIntent.mockImplementation((options: unknown) => { - (options as { applyEager: () => unknown }).applyEager(); + const runner = options as { + applyEager: () => unknown; + onRolledBack?: () => void; + }; + runner.applyEager(); + runner.onRolledBack?.(); return Promise.reject(new Error('start failed')); }); const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + // 우리가 쓴 값이 그대로 남아 있는 상태 (CAS 통과) + mocks.pluginElements = [{ fullId: 'plugin-x:one', zIndex: 0 }]; + mocks.setPluginZIndexes.mockImplementation( + (updates: Array<{ fullId: string; zIndex: number }>) => { + mocks.pluginElements = updates.map((update) => ({ ...update })); + }, + ); await dragItemToEnd(startItems, { layerItems: liveItems, displayItems: toDisplay(liveItems), @@ -318,14 +343,59 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { await act(async () => { await Promise.resolve(); }); + mocks.setPluginZIndexes.mockImplementation(() => {}); - // 쓰기 1회 + 복원 1회, 복원은 드래그 전 z로 되돌린다 expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(2); expect(mocks.setPluginZIndexes.mock.calls[1][0]).toEqual([ { fullId: 'plugin-x:one', zIndex: 0 }, ]); }); + it('편입 후 실패는 plugin z를 되돌리지 않는다', async () => { + // 편입 후 transient 실패는 pendingLocal 재시도가 소유한다 - 러너도 + // native receipt를 되돌리지 않으므로 plugin도 그대로 둔다 + mocks.runElementIntent.mockImplementation((options: unknown) => { + (options as { applyEager: () => unknown }).applyEager(); + return Promise.reject(new Error('transient after enrollment')); + }); + const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; + const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + await dragItemToEnd(startItems, { + layerItems: liveItems, + displayItems: toDisplay(liveItems), + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(1); + }); + + it('다른 writer가 z를 바꿨으면 복원하지 않는다', async () => { + mocks.runElementIntent.mockImplementation((options: unknown) => { + const runner = options as { + applyEager: () => unknown; + onRolledBack?: () => void; + }; + runner.applyEager(); + runner.onRolledBack?.(); + return Promise.reject(new Error('start failed')); + }); + const startItems = [nativeItem(ID_A, 0, 2), nativeItem(ID_B, 1, 1)]; + const liveItems = [...startItems, pluginItem('plugin-x:one', 0)]; + // 복원 시점에 우리가 쓴 값이 아니다 - 그쪽 소유이므로 건드리지 않는다 + mocks.pluginElements = [{ fullId: 'plugin-x:one', zIndex: 99 }]; + await dragItemToEnd(startItems, { + layerItems: liveItems, + displayItems: toDisplay(liveItems), + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(mocks.setPluginZIndexes).toHaveBeenCalledTimes(1); + }); + it('native 전용 편입 전 실패는 runner가 소유하고 layerGroups는 eager를 건드리지 않는다', async () => { mocks.runElementIntent.mockImplementation((options: unknown) => { // 러너 계약 재현: eager 적용 후 편입 전 실패 @@ -465,6 +535,46 @@ describe('useLayerDnD 커밋 경로 라우팅', () => { expect(byId.get(ID_B)).toMatchObject({ zIndex: 0 }); }); + it('접힌 그룹 헤더 하단 드롭은 그룹 편입 의도를 유지한다', async () => { + const child = nativeItem(ID_M1, 0, 3, 'G'); + const outsider = nativeItem(ID_X, 1, 2); + // 드롭 자리 다음에 그룹 밖 행이 있어야 재유도가 undefined를 낸다 - + // 드래그 항목이 마지막이면 두 경로가 우연히 같은 값을 낸다 + const tail = nativeItem(ID_Y, 2, 1); + const collapsedHeader: DisplayItem = { + displayType: 'group-header', + groupId: 'G', + groupName: 'G', + isCollapsed: true, + childCount: 1, + allHidden: false, + }; + // 접힌 그룹은 자식 행을 렌더하지 않는다 - 헤더 다음은 무그룹 레이어다 + const display: DisplayItem[] = [ + collapsedHeader, + { displayType: 'layer', item: outsider, groupDepth: 0, flatIndex: 1 }, + { displayType: 'layer', item: tail, groupDepth: 0, flatIndex: 2 }, + ]; + await renderDnD({ + layerItems: [child, outsider, tail], + displayItems: display, + liveModel: { + layerItems: [child, outsider, tail], + displayItems: display, + }, + }); + + // 헤더 행(높이 24)의 하단 절반에 드롭 + await finishDrag( + () => api.handleMouseDown(mouseDownEvent(), outsider, 1), + 18, + ); + + expect(mocks.applyGestureEagerly).toHaveBeenCalledTimes(1); + // 재유도가 헤더의 명시 의도를 덮으면 groupId가 undefined로 커밋된다 + expect(eagerIntents().get(ID_X)).toMatchObject({ groupId: 'G' }); + }); + it('그룹+추가 선택 드래그는 mouseup 시점 live 구성원 전체를 함께 옮긴다', async () => { const itemA = nativeItem(ID_A, 0, 3, 'G'); const itemX = nativeItem(ID_X, 1, 2); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts index 0fc1371c..e4dade38 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerDnD.ts @@ -17,6 +17,10 @@ import { } from '@src/renderer/editor/runtime/elementIntent'; import { applyEditorPatch } from '@src/renderer/editor/runtime/editorCoordinator'; import { setPluginElementZIndexes } from '@plugins/rpc/pluginElementActions'; +import { + selectPropertyPanelPluginElements, + usePluginDisplayElementStore, +} from '@stores/plugin/usePluginDisplayElementStore'; import { useState, useRef } from 'react'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { normalizeLayerGroupsForMode } from '@utils/layerGroupUtils'; @@ -479,6 +483,10 @@ export function useLayerDnD({ // plugin-only는 editor 무커밋, wire patch는 슬롯 base에서 재생성한다 - // 호출 시점 full-record는 대기 중 정산된 격리 plugin 쓰기를 되돌린다. // plugin z-index는 별도 authority 쓰기로 editor 커밋과 비원자(기존 의미론) + // 렌더 목록과 같은 창 인지 selector - 분리 패널은 panelElements만 채운다 + const pluginElementsForZ = () => + selectPropertyPanelPluginElements(usePluginDisplayElementStore.getState()); + const commitDropIntents = (sets: DropIntentSets, skipContext: string) => { const hasPluginZ = sets.pluginZIndexUpdates.length > 0; const hasNativeIntent = @@ -503,21 +511,29 @@ export function useLayerDnD({ return; } // plugin z는 별도 authority 쓰기라 editor 커밋과 비원자다(기존 의미론). - // 편입 후 실패는 되돌려 반쪽 순서가 남지 않게 한다 + // native receipt가 실제로 되돌아간 경우에만 함께 되돌린다 - 편입 후 + // transient 실패는 pendingLocal 재시도가 소유하므로 복원하면 안 된다 if (hasPluginZ) { setPluginElementZIndexes(sets.pluginZIndexUpdates); } const restorePluginZ = () => { if (!hasPluginZ) return; - setPluginElementZIndexes( - sets.pluginZIndexUpdates.map((update) => ({ + // 우리가 쓴 값이 그대로일 때만 되돌린다 - 그 사이 다른 writer가 바꿨으면 + // 그쪽 소유다 (native 속성 receipt의 CAS와 같은 규칙) + const current = new Map( + pluginElementsForZ().map((element) => [element.fullId, element.zIndex]), + ); + const reverts = sets.pluginZIndexUpdates + .filter((update) => current.get(update.fullId) === update.zIndex) + .map((update) => ({ fullId: update.fullId, zIndex: update.beforeZIndex, - })), - ); + })); + if (reverts.length > 0) setPluginElementZIndexes(reverts); }; void runElementIntent({ applyEager: () => eager.receipt, + onRolledBack: restorePluginZ, generate: (base) => { if ( hasSynthetic && @@ -572,14 +588,10 @@ export function useLayerDnD({ }) .then((result) => { if (!result.committed && !result.satisfied) { - restorePluginZ(); reportElementOpSkipped(skipContext); } }) - .catch((error) => { - restorePluginZ(); - reportElementOpError(error); - }); + .catch(reportElementOpError); }; const performMultiDrop = async ( @@ -1000,15 +1012,19 @@ export function useLayerDnD({ liveModel.displayItems, ); if (resolvedIndex != null) { - // 그룹도 live 모델로 재유도 - 캡처 시점 값을 그대로 쓰면 앵커가 - // 그 사이 그룹을 떠났을 때 사용자가 넣지 않은 그룹에 편입된다 - const dropTarget = resolveItemDropTarget( - resolvedIndex, - new Set(draggedIds), - liveModel, - ); + // 접힌 그룹 헤더 하단 드롭은 그룹 편입이 사용자의 명시적 의도이고 + // 헤더 생존은 앵커 해석이 이미 확인했다. 그 외에는 live 모델로 + // 재유도 - 캡처 값을 그대로 쓰면 앵커가 그 사이 그룹을 떠났을 때 + // 사용자가 넣지 않은 그룹에 편입된다 + const targetGroupId = target.anchorHeaderGroupId + ? target.anchorHeaderGroupId + : resolveItemDropTarget( + resolvedIndex, + new Set(draggedIds), + liveModel, + ).targetGroupId; performMultiDrop(draggedIds, resolvedIndex, { - targetGroupId: dropTarget.targetGroupId, + targetGroupId, liveModel, }); } diff --git a/src/renderer/editor/runtime/elementIntent.ts b/src/renderer/editor/runtime/elementIntent.ts index b1d451e4..5723c284 100644 --- a/src/renderer/editor/runtime/elementIntent.ts +++ b/src/renderer/editor/runtime/elementIntent.ts @@ -70,10 +70,17 @@ export const runElementIntent = async (options: { applyEager: () => ElementIntentReceipt | null; generate: (base: EditorDocumentV1) => ElementIntentGeneration; gestureId?: string; + // eager receipt를 실제로 되돌린 시점에만 불린다. editor 밖 authority 쓰기를 + // 같이 되돌려야 하는 호출부가 편입 전/후 정책을 러너와 일치시키는 용도 + onRolledBack?: () => void; }): Promise => { const receipt = options.applyEager(); let enrolled = false; let lastKind: ElementIntentGeneration['kind'] | null = null; + const rollback = () => { + receipt?.rollback(); + options.onRolledBack?.(); + }; try { const document = await enqueueEditorCompatibilityOperation(() => editorCoordinator.commitGeneratedPatch( @@ -91,7 +98,7 @@ export const runElementIntent = async (options: { ), ); if (lastKind === 'targetLost') { - receipt?.rollback(); + rollback(); return { committed: false, satisfied: false, document: null }; } if (lastKind === 'satisfied') { @@ -99,7 +106,7 @@ export const runElementIntent = async (options: { } return { committed: true, satisfied: true, document }; } catch (error) { - if (!enrolled) receipt?.rollback(); + if (!enrolled) rollback(); if (isElementIntentAbort(error) && !enrolled) { // 전체 중단은 오류가 아니라 fail-closed 무커밋 return { committed: false, satisfied: false, document: null }; From 6c20c1dfb6962a874a4d6d772e03df87e31c2d6e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 17:16:48 +0900 Subject: [PATCH 17/19] =?UTF-8?q?fix:=20=EC=84=A0=ED=83=9D=20=EC=9E=AC?= =?UTF-8?q?=EC=B1=84=ED=83=9D=EC=97=90=20=EC=9E=90=EB=A6=AC=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=9D=BC=EC=B9=98=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 길이 불변만으로 재채택하면 삭제+삽입이 한 문서에 정산된 경우처럼 자리의 요소가 교체된 상황에서도 선택이 무관한 요소에 재결합된다. 붙여넣기가 실패해 죽은 선택이 남은 뒤 같은 자리에 새 요소가 생기면 같은 요소가 선택에 중복으로 들어가 이동 폭이 배가 되기도 한다. 그 자리 요소가 id만 빼고 동일할 때만 재채택하고, 다르면 기존처럼 선택을 푼다. Co-Authored-By: Claude Fable 5 --- .../useGridSelectionStore.idReconcile.test.ts | 15 +++++++++++++++ .../stores/grid/useGridSelectionStore.ts | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts index ce81e55d..eaa3a4ec 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts @@ -79,6 +79,21 @@ describe('id 기반 선택 재조정', () => { expect(selected[0].index).toBe(1); }); + it('같은 길이라도 그 자리 요소가 교체됐으면 선택을 푼다', () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: a.id!, index: 1 }]); + // 삭제+삽입이 한 문서에 정산되면 길이는 그대로지만 자리의 요소는 다르다 + const replacement = { ...createDefaultKeyPosition(), dx: 999 }; + + invalidateSelectionForChangedIndexedElementArrays( + arraysOf([b, a]), + arraysOf([b, replacement]), + ); + + expect(useGridSelectionStore.getState().selectedElements).toHaveLength(0); + }); + it('길이가 바뀌면 재채택 대신 경계 판정을 따른다', () => { useGridSelectionStore .getState() diff --git a/src/renderer/stores/grid/useGridSelectionStore.ts b/src/renderer/stores/grid/useGridSelectionStore.ts index 02897b39..b170ffcd 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.ts @@ -397,6 +397,16 @@ export function invalidateSelectionForChangedIndexedElementArrays( id?: string; }[]; + const currentPositionsFor = ( + type: IndexedSelectableElementType, + ): readonly unknown[] => + type === 'key' ? current.keyPositions : current[type]; + + // 같은 자리의 요소가 id만 빼고 동일한지 - 신원 재발급과 요소 교체를 가른다 + const sameSlotPayload = (type: IndexedSelectableElementType, index: number) => + stableStringify(withoutElementId(currentPositionsFor(type)[index])) === + stableStringify(withoutElementId(nextPositionsFor(type)[index])); + let changed = false; const selectedElements = selection.selectedElements.flatMap((element) => { if (element.type === 'plugin') return [element]; @@ -416,11 +426,13 @@ export function invalidateSelectionForChangedIndexedElementArrays( return [element]; } - // id가 사라졌어도 그 타입에 구조 경계가 없으면 요소는 그대로이고 신원만 - // 재발급된 것이다 (탭 프리셋 rekey, v1 어댑터 재발급) - 자리로 재채택한다 + // id가 사라졌어도 그 자리의 요소가 id만 빼고 그대로면 신원만 재발급된 + // 것이다 (탭 프리셋 rekey, v1 어댑터 재발급) - 자리로 재채택한다. + // 내용이 다르면 요소가 교체된 것이므로 예전처럼 선택을 푼다 const rekeyed = boundaries.get(element.type) === undefined && - typeof element.index === 'number' + typeof element.index === 'number' && + sameSlotPayload(element.type, element.index) ? nextPositions[element.index]?.id : undefined; if (rekeyed !== undefined) { From bd8a507aa0fb903fa46dbd761af36d06a71c5f3f Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 17:22:07 +0900 Subject: [PATCH 18/19] =?UTF-8?q?fix:=20=ED=94=84=EB=A6=AC=EC=85=8B=20?= =?UTF-8?q?=EA=B4=80=EC=9A=A9=EC=9D=84=20=EB=9D=BC=EB=B2=A8=C2=B7=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=9C=84=EB=B0=98=EA=B9=8C=EC=A7=80=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한도 초과 관용만 자리 기준으로 바꿔서, 키 라벨 길이 관용과 그림자·노브 수치 같은 구조 위반 관용은 여전히 id 조회에 남아 있었다. 프리셋 로드가 id를 재발급하므로 그 관용들은 전부 소실되고, 그림자나 과길이 라벨을 가진 store는 여전히 자기 프리셋 백업을 되돌릴 수 없었다. 라벨 조회를 같은 헬퍼 규칙으로 통일하고, 구조 위반은 후보 id를 같은 자리의 현재 id로 치환해 한 번 더 대조한다. 일반 편집 경로는 치환표가 비어 있어 동작이 그대로다. Co-Authored-By: Claude Fable 5 --- src-tauri/src/state/editor.rs | 182 ++++++++++++++++++++++++++++++++-- 1 file changed, 171 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index b68896f3..e148eb1f 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -10,8 +10,8 @@ use crate::{ errors::EditorCommitError, models::{ AppStoreData, CustomTab, EditorCommitRequest, EditorDocumentV1, EditorField, - ElementShadowSpec, KeyCounters, KeyMappings, KeyPosition, KeySlot, - EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, + ElementShadowSpec, GraphPosition, KeyCounters, KeyMappings, KeyPosition, KeySlot, + KnobPosition, StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, }, }; @@ -497,9 +497,13 @@ pub(crate) fn validate_document_transition_with_keying( .collect::>(); validate_metric_limits(current, candidate, keying)?; + let native_id_alias = match keying { + GrandfatherKeying::ById => HashMap::new(), + GrandfatherKeying::ByModeIndex => native_id_alias_by_slot(current, candidate), + }; if let Some(violation) = candidate_violations.iter().find(|violation| { is_unconditional_structural_violation(violation.code()) - || !is_grandfathered(¤t_violation_keys, violation) + || !is_grandfathered(¤t_violation_keys, violation, &native_id_alias) }) { return Err(EditorCommitError::validation( violation.code(), @@ -510,11 +514,129 @@ pub(crate) fn validate_document_transition_with_keying( Ok(()) } +// 후보 요소 id → 같은 (모드, 자리)의 현재 요소 id. 프리셋 트랜잭션은 커밋 +// 직전 id를 재발급하므로 신원으로는 관용 상대를 찾을 수 없다 +fn native_id_alias_by_slot( + current: &EditorDocumentV1, + candidate: &EditorDocumentV1, +) -> HashMap { + let mut alias = HashMap::new(); + let mut collect = |current_ids: Vec<(&String, Vec<&str>)>, + candidate_ids: Vec<(&String, Vec<&str>)>| { + let current_by_mode = current_ids.into_iter().collect::>(); + for (mode, ids) in candidate_ids { + let Some(current_mode_ids) = current_by_mode.get(mode) else { + continue; + }; + for (index, id) in ids.into_iter().enumerate() { + if let Some(current_id) = current_mode_ids.get(index) { + alias.insert(id.to_string(), current_id.to_string()); + } + } + } + }; + + collect( + key_position_ids(¤t.key_positions), + key_position_ids(&candidate.key_positions), + ); + collect( + nested_position_ids(¤t.stat_positions), + nested_position_ids(&candidate.stat_positions), + ); + collect( + nested_position_ids(¤t.graph_positions), + nested_position_ids(&candidate.graph_positions), + ); + collect( + nested_position_ids(¤t.knob_positions), + nested_position_ids(&candidate.knob_positions), + ); + alias +} + +fn key_position_ids(collection: &HashMap>) -> Vec<(&String, Vec<&str>)> { + collection + .iter() + .map(|(mode, positions)| { + ( + mode, + positions + .iter() + .map(|position| position.id.as_str()) + .collect(), + ) + }) + .collect() +} + +fn nested_position_ids( + collection: &HashMap>, +) -> Vec<(&String, Vec<&str>)> { + collection + .iter() + .map(|(mode, positions)| { + ( + mode, + positions + .iter() + .map(|element| element.key_position().id.as_str()) + .collect(), + ) + }) + .collect() +} + +trait HasKeyPosition { + fn key_position(&self) -> &KeyPosition; +} + +impl HasKeyPosition for StatPosition { + fn key_position(&self) -> &KeyPosition { + &self.position + } +} + +impl HasKeyPosition for GraphPosition { + fn key_position(&self) -> &KeyPosition { + &self.position + } +} + +impl HasKeyPosition for KnobPosition { + fn key_position(&self) -> &KeyPosition { + &self.position + } +} + fn is_grandfathered( current_violation_keys: &BTreeSet, candidate: &ValidationViolation, + native_id_alias: &HashMap, ) -> bool { - current_violation_keys.contains(&candidate.key) + if current_violation_keys.contains(&candidate.key) { + return true; + } + if native_id_alias.is_empty() { + return false; + } + // 재발급된 id는 같은 자리의 이전 신원으로 바꿔 한 번 더 대조한다 + let ViolationOwner::NativeElement { kind, id } = &candidate.key.owner else { + return false; + }; + let Some(current_id) = native_id_alias.get(id) else { + return false; + }; + let aliased = ViolationKey { + owner: ViolationOwner::NativeElement { + kind: *kind, + id: current_id.clone(), + }, + code: candidate.key.code, + property_path: candidate.key.property_path.clone(), + invalid_value: candidate.key.invalid_value.clone(), + }; + current_violation_keys.contains(&aliased) } fn is_unconditional_structural_violation(code: &str) -> bool { @@ -971,12 +1093,18 @@ fn validate_per_owner_metric_limits( for (mode, keys) in &candidate.keys { for (slot_index, slot) in keys.iter().enumerate() { - let current_slot = candidate - .key_positions - .get(mode) - .and_then(|positions| positions.get(slot_index)) - .and_then(|position| current_key_slots.get(position.id.as_str())) - .copied(); + let current_slot = match keying { + GrandfatherKeying::ById => candidate + .key_positions + .get(mode) + .and_then(|positions| positions.get(slot_index)) + .and_then(|position| current_key_slots.get(position.id.as_str())) + .copied(), + GrandfatherKeying::ByModeIndex => current + .keys + .get(mode) + .and_then(|slots| slots.get(slot_index)), + }; validate_key_slot_label_limits(mode, slot_index, current_slot, slot)?; } } @@ -2054,7 +2182,8 @@ mod tests { assert!(is_grandfathered( ¤t, - &ValidationViolation::new(key, "different diagnostic message") + &ValidationViolation::new(key, "different diagnostic message"), + &HashMap::new() )); } @@ -2250,6 +2379,37 @@ mod tests { .unwrap(); } + #[test] + fn preset_keying_grandfathers_structural_and_label_violations_too() { + let mut store = default_editor_store(); + // 그림자 위반과 과길이 라벨을 함께 가진 관용 store + store.key_positions.get_mut("4key").unwrap()[0].shadow = Some(ElementShadowSpec { + color: String::new(), + ..valid_shadow() + }); + store.keys.get_mut("4key").unwrap()[0] = KeySlot::from("x".repeat(MAX_KEY_LABEL_BYTES + 1)); + let current = EditorDocumentV1::from_store(&store); + + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + // ID 기준으로는 관용 상대를 못 찾아 거부된다 + assert!( + validate_document_transition(¤t, &candidate, &store, &candidate_store).is_err() + ); + + // 자리 기준이면 그림자·라벨 관용이 모두 유지된다 + validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::ByModeIndex, + ) + .unwrap(); + } + #[test] fn preset_keying_still_rejects_newly_raised_metrics() { let mut store = default_editor_store(); From 65025c20564d390b13117e224de1538160aef481 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Thu, 13 Aug 2026 17:41:18 +0900 Subject: [PATCH 19/19] =?UTF-8?q?docs:=20=ED=94=84=EB=A6=AC=EC=85=8B=20ID?= =?UTF-8?q?=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=B2=94=EC=9C=84=EC=99=80=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=B2=98=EB=A6=AC=20=EC=84=9C=EC=88=A0=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탭 프리셋이 위치를 준 컬렉션만 재발급하도록 바뀐 것과, 중복 id가 거부 대신 사본 재발급으로 처리되는 것을 Keys 페이지 en/ko에 반영한다. Co-Authored-By: Claude Fable 5 --- docs/content/en/api-reference/keys/page.mdx | 11 +++++++---- docs/content/ko/api-reference/keys/page.mdx | 10 ++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 0af203c0..0e9ff8c7 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -296,10 +296,13 @@ interface ElementShadowSpec { Every element position (`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`) carries a stable `id`. Treat it as opaque: echo back the value -you read, never invent one, and never copy an `id` onto another element. A -write whose `id` is missing or unknown gets a fresh one assigned by the -backend, and loading a preset re-issues every `id`. Use `id` to track an -element across reorders instead of its array index. +you read and never invent one. A write whose `id` is missing or unknown gets a +fresh one assigned by the backend. When the same `id` appears more than once — +for example when you copy an element you read and append it — the element in +its original slot keeps the identity and the remaining copies get fresh ones. +Loading a full preset re-issues every `id`; a tab preset re-issues only the +collections that preset supplies positions for. Use `id` to track an element +across reorders instead of its array index. When a gradient field is present it takes priority over the matching solid field, and the solid field is kept in sync with the first stop color on save. diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 4237d347..f5bb1ac1 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -163,10 +163,12 @@ interface ElementShadowSpec { 모든 요소 위치(`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을 -그대로 되돌려 보내고, 직접 만들거나 다른 요소에 복사하지 마세요. `id`가 -없거나 미확인인 쓰기는 백엔드가 새 값을 발급하며, 프리셋을 불러오면 모든 -`id`가 재발급됩니다. 재정렬을 가로질러 요소를 추적할 때는 배열 index 대신 -`id`를 사용하세요. +그대로 되돌려 보내고, 직접 만들지 마세요. `id`가 없거나 미확인인 쓰기는 +백엔드가 새 값을 발급합니다. 같은 `id`가 여러 번 나오면(읽은 요소를 복사해 +덧붙이는 경우처럼) 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 +받습니다. 전체 프리셋을 불러오면 모든 `id`가 재발급되고, 탭 프리셋은 그 +프리셋이 위치를 준 컬렉션만 재발급됩니다. 재정렬을 가로질러 요소를 추적할 +때는 배열 index 대신 `id`를 사용하세요. 그라데이션 필드가 있으면 렌더에서 대응 단색 필드보다 우선하며, 저장 시 대응 단색 필드는 첫 스톱 색으로 자동 동기화됩니다. 단색으로 되돌리려면