From e26d43860d00d04805c598d4809fdbc6eeb6a985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 21 Jul 2026 14:55:30 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20=EC=86=8D=EC=84=B1=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=B6=84=EB=A6=AC=20=EA=B0=80=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EC=99=80=20=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=20=EA=B8=B0=EB=B3=B8=20=ED=81=AC=EA=B8=B0=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/declarative-api/page.mdx | 1 + docs/content/en/guide/settings/page.mdx | 7 +++++++ docs/content/ko/declarative-api/page.mdx | 1 + docs/content/ko/guide/settings/page.mdx | 7 +++++++ 4 files changed, 16 insertions(+) diff --git a/docs/content/en/declarative-api/page.mdx b/docs/content/en/declarative-api/page.mdx index 186c2199..e28fba9a 100644 --- a/docs/content/en/declarative-api/page.mdx +++ b/docs/content/en/declarative-api/page.mdx @@ -309,6 +309,7 @@ Use `resizable` option to let users resize elements directly in the grid. ### resizable Set `resizable: true` to show 8-direction resize handles: +The initial size is 200×150. A saved or estimated size takes precedence when available. ```javascript dmn.plugin.defineElement({ diff --git a/docs/content/en/guide/settings/page.mdx b/docs/content/en/guide/settings/page.mdx index 4543913c..3331b11e 100644 --- a/docs/content/en/guide/settings/page.mdx +++ b/docs/content/en/guide/settings/page.mdx @@ -39,6 +39,13 @@ When enabled, the overlay window ignores mouse events (click-through) allowing y When locked, you cannot directly interact with the overlay. +### Detaching the Properties Panel + +Use the detach button at the top of the properties panel to move it into a separate window at any time. Key, note, and counter editing as well as layer management work the same in the detached window. + +- Use the X button on the detached window or the reattach button at the top of the panel to return it inline. +- While detached, picking a gradient color anchor on the canvas is limited. + ## Graphics Settings ### Rendering Option diff --git a/docs/content/ko/declarative-api/page.mdx b/docs/content/ko/declarative-api/page.mdx index 4cdf77f8..3e6a6837 100644 --- a/docs/content/ko/declarative-api/page.mdx +++ b/docs/content/ko/declarative-api/page.mdx @@ -308,6 +308,7 @@ dmn.plugin.defineElement({ ### resizable `resizable: true`로 설정하면 요소에 8방향 리사이즈 핸들이 표시됩니다. +첫 생성 크기는 200×150이며, 저장된 크기나 추정 크기가 있으면 해당 값을 우선합니다. ```javascript dmn.plugin.defineElement({ diff --git a/docs/content/ko/guide/settings/page.mdx b/docs/content/ko/guide/settings/page.mdx index 324100a6..264b218a 100644 --- a/docs/content/ko/guide/settings/page.mdx +++ b/docs/content/ko/guide/settings/page.mdx @@ -38,6 +38,13 @@ DM Note의 다양한 설정 옵션을 안내합니다. 고정을 켠 상태에서는 오버레이를 직접 조작할 수 없습니다. +### 속성 패널 분리 + +캔버스 우측 속성 패널의 상단 분리 버튼을 누르면 언제든 별도 창으로 분리할 수 있습니다. 분리된 창에서도 키·노트·카운터 편집과 레이어 관리를 동일하게 사용할 수 있습니다. + +- 분리 창의 X 버튼 또는 패널 상단의 되돌리기 버튼으로 다시 인라인 패널로 복귀합니다. +- 분리 상태에서는 그라디언트 색상의 캔버스 앵커 지정이 제한됩니다. + ## 그래픽 설정 ### 렌더링 옵션 From 221fce46482420c8faac6f3ba395cd445920e5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 21 Jul 2026 19:03:06 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=ED=94=84=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EA=B2=8C=EC=8A=A4=EC=B2=98=EB=A5=BC=20gestureIds=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=EC=BB=A4=EB=B0=8B=EC=9C=BC=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EC=82=B0=ED=95=98=EA=B3=A0=20revision=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/editor/page.mdx | 28 ++- docs/content/ko/api-reference/editor/page.mdx | 33 +++- src-tauri/src/commands/editor/preview.rs | 3 +- src-tauri/src/commands/editor/state.rs | 18 +- src-tauri/src/errors.rs | 20 ++ src-tauri/src/models/editor.rs | 23 +++ src-tauri/src/services/preview_broker.rs | 76 ++++---- src-tauri/src/state/editor.rs | 61 +++++- src-tauri/src/state/store.rs | 24 ++- .../__tests__/previewOverlaySession.test.ts | 59 ++---- .../api/modules/editorAdapters.test.ts | 7 +- src/renderer/api/modules/keysApi.ts | 22 ++- src/renderer/api/modules/previewApi.ts | 4 +- .../editor/runtime/editGestureController.ts | 35 ++-- .../editor/runtime/editorCoordinator.test.ts | 184 +++++++++++++++++- .../editor/runtime/editorCoordinator.ts | 125 +++++++----- .../editorGestureDiscardIntegration.test.ts | 126 ++++++++++++ .../runtime/editorStateCoordinator.test.ts | 124 ++++++++++++ .../editor/runtime/editorStateCoordinator.ts | 44 ++++- .../editor/runtime/gestureSessionLifecycle.ts | 31 +++ src/renderer/editor/runtime/previewOverlay.ts | 46 +---- src/renderer/hooks/app/useAppBootstrap.ts | 11 -- src/types/editor.ts | 16 ++ src/types/preview.ts | 2 - 24 files changed, 882 insertions(+), 240 deletions(-) create mode 100644 src/renderer/editor/runtime/editorGestureDiscardIntegration.test.ts create mode 100644 src/renderer/editor/runtime/editorStateCoordinator.test.ts create mode 100644 src/renderer/editor/runtime/gestureSessionLifecycle.ts diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index d3c58871..585e5ee5 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -85,6 +85,8 @@ firmware that violates those barriers. interface EditorCommitRequest { baseRevision: number; mutationId: string; + gestureId?: string; + gestureIds?: string[]; changes: EditorPatchV1; } @@ -113,6 +115,12 @@ intended for immediate IPC retries; the in-memory deduplication window does not survive an app restart. Reusing a retained ID for a different request is rejected. +`gestureId` is the representative history gesture. `gestureIds` carries every +preview session coalesced into the request so the committed event can echo the +complete set. Both fields are optional and accept UUIDs only. The `gestureIds` +array can contain at most 32 entries, and the combined unique set across both +fields is also limited to 32 IDs. + If the submitted values are already current, the result keeps the current revision, returns an empty `changedFields`, and emits no canonical committed event. Compatibility wrappers may still project their legacy per-field refresh @@ -153,6 +161,8 @@ the human-readable `message`. type EditorCommitErrorCode = | 'REVISION_CONFLICT' | 'VALIDATION_FAILED' + | 'TOO_MANY_GESTURE_IDS' + | 'INVALID_GESTURE_ID' | 'PAIRED_UPDATE_REQUIRED' | 'MUTATION_ID_REUSED' | 'IO_ERROR'; @@ -169,13 +179,15 @@ interface EditorCommitError { } ``` -| Code | Meaning | `retryable` | -| ------------------------ | ---------------------------------------------------------- | ----------- | -| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` | -| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` | -| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` | -| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` | -| `IO_ERROR` | The document could not be persisted | `true` | +| Code | Meaning | `retryable` | +| ------------------------ | ------------------------------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` | +| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` | +| `TOO_MANY_GESTURE_IDS` | `gestureIds` exceeds 32 entries or the combined unique set exceeds 32 IDs | `false` | +| `INVALID_GESTURE_ID` | A gesture ID is not a UUID within the 64-byte limit | `false` | +| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` | +| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` | +| `IO_ERROR` | The document could not be persisted | `true` | `details.currentRevision`, `details.validationCode`, or `details.field` is included when it applies to the error. @@ -192,6 +204,8 @@ interface EditorCommittedV1 { schemaVersion: 1; revision: number; mutationId: string; + gestureId?: string | null; + gestureIds?: string[]; origin?: string; changedFields: EditorField[]; patch: EditorPatchV1; diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index 8e1c447e..84e1f38e 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -75,15 +75,17 @@ console.log(revision, document.keyPositions); 고장까지 애플리케이션이 절대 보장할 수는 없습니다. - 앱의 Undo/Redo도 편집 컬렉션 6개, 커스텀 탭 정보, 선택 모드, 카운터, - 프리셋 설정, 탭별 노트 설정을 백엔드 store 트랜잭션 한 번으로 함께 복원합니다. - 이 내부 커맨드는 공개 플러그인 API로 노출하지 않습니다. + 앱의 Undo/Redo도 편집 컬렉션 6개, 커스텀 탭 정보, 선택 모드, 카운터, 프리셋 + 설정, 탭별 노트 설정을 백엔드 store 트랜잭션 한 번으로 함께 복원합니다. 이 + 내부 커맨드는 공개 플러그인 API로 노출하지 않습니다. ```typescript interface EditorCommitRequest { baseRevision: number; mutationId: string; + gestureId?: string; + gestureIds?: string[]; changes: EditorPatchV1; } @@ -111,6 +113,11 @@ console.log('커밋된 revision:', result.revision); 방지는 즉시 IPC 재시도용이며 앱을 재시작하면 유지되지 않습니다. 아직 보관 중인 ID를 다른 요청에 재사용하면 거절됩니다. +`gestureId`는 히스토리 병합의 대표 게스처입니다. `gestureIds`는 요청 하나로 +합쳐진 모든 프리뷰 세션을 전달하여 committed 이벤트가 전체 집합을 echo하게 +합니다. 두 필드는 모두 선택 사항이며 UUID만 허용합니다. `gestureIds` 배열은 +항목이 최대 32개이고, 두 필드를 합친 고유 ID 집합도 최대 32개로 제한됩니다. + 제출한 값이 이미 현재 값과 같으면 현재 revision과 빈 `changedFields`를 반환하고 canonical committed 이벤트는 발행하지 않습니다. 호환 wrapper는 예전 필드별 refresh 이벤트를 한 번 투영할 수 있지만, 같은 `mutationId` 재시도에서는 @@ -151,6 +158,8 @@ await dmn.editor.commit({ type EditorCommitErrorCode = | 'REVISION_CONFLICT' | 'VALIDATION_FAILED' + | 'TOO_MANY_GESTURE_IDS' + | 'INVALID_GESTURE_ID' | 'PAIRED_UPDATE_REQUIRED' | 'MUTATION_ID_REUSED' | 'IO_ERROR'; @@ -167,13 +176,15 @@ interface EditorCommitError { } ``` -| 코드 | 의미 | `retryable` | -| ------------------------ | ------------------------------------------------- | ----------- | -| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` | -| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` | -| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` | -| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` | -| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` | +| 코드 | 의미 | `retryable` | +| ------------------------ | --------------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` | +| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` | +| `TOO_MANY_GESTURE_IDS` | `gestureIds`가 32개를 넘거나 합친 고유 ID가 32개를 초과함 | `false` | +| `INVALID_GESTURE_ID` | gesture ID가 UUID가 아니거나 64바이트를 초과함 | `false` | +| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` | +| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` | +| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` | 오류에 해당할 때 `details.currentRevision`, `details.validationCode`, `details.field`가 함께 제공됩니다. @@ -190,6 +201,8 @@ interface EditorCommittedV1 { schemaVersion: 1; revision: number; mutationId: string; + gestureId?: string | null; + gestureIds?: string[]; origin?: string; changedFields: EditorField[]; patch: EditorPatchV1; diff --git a/src-tauri/src/commands/editor/preview.rs b/src-tauri/src/commands/editor/preview.rs index 9298703a..d00efe6e 100644 --- a/src-tauri/src/commands/editor/preview.rs +++ b/src-tauri/src/commands/editor/preview.rs @@ -25,7 +25,6 @@ pub fn editor_preview_cancel( broker: State<'_, PreviewBroker>, window: WebviewWindow, session_id: String, - min_revision: Option, ) -> Result<(), String> { - broker.cancel(window.label(), &session_id, min_revision) + broker.cancel(window.label(), &session_id) } diff --git a/src-tauri/src/commands/editor/state.rs b/src-tauri/src/commands/editor/state.rs index 41f81f38..4aa6f8c6 100644 --- a/src-tauri/src/commands/editor/state.rs +++ b/src-tauri/src/commands/editor/state.rs @@ -200,7 +200,7 @@ pub fn editor_commit( let admission = state .admit_frontend_history_mutation(window.label()) .map_err(|_| crate::errors::EditorCommitError::history_in_progress())?; - let gesture_id = request.gesture_id.clone(); + let gesture_ids = request.echoed_gesture_ids(); let requested_fields = request.changes.included_fields(); let previous_mode = requested_fields .contains(&EditorField::Keys) @@ -208,6 +208,13 @@ pub fn editor_commit( let change = state .store .commit_editor_document_admitted(request, &admission)?; + for gesture_id in &gesture_ids { + if let Err(error) = + broker.finish_committed_session(window.label(), gesture_id, change.event.is_none()) + { + log::warn!("failed to finish committed preview session: {error}"); + } + } if change.event.is_some() { publish_editor_change(state.inner(), &app, &change, false); } @@ -221,15 +228,6 @@ pub fn editor_commit( ); } } - if let Some(gesture_id) = gesture_id { - if let Err(error) = broker.finish_committed_session( - window.label(), - &gesture_id, - Some(change.result.revision), - ) { - log::warn!("failed to finish committed preview session: {error}"); - } - } if let Some(history_status) = change.history_status.as_ref() { emit_best_effort(&app, "history:status", history_status); } diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 705559df..06f6e545 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -5,6 +5,8 @@ use serde::Serialize; pub enum EditorCommitErrorCode { RevisionConflict, ValidationFailed, + TooManyGestureIds, + InvalidGestureId, PairedUpdateRequired, MutationIdReused, HistoryInProgress, @@ -60,6 +62,24 @@ impl EditorCommitError { } } + pub fn too_many_gesture_ids(max: usize) -> Self { + Self { + error_code: EditorCommitErrorCode::TooManyGestureIds, + message: format!("gesture ID count exceeds {max}"), + details: None, + retryable: false, + } + } + + pub fn invalid_gesture_id() -> Self { + Self { + error_code: EditorCommitErrorCode::InvalidGestureId, + message: "gesture IDs must be UUIDs no longer than 64 bytes".to_string(), + details: None, + retryable: false, + } + } + pub fn paired_update_required(field: impl Into) -> Self { let field = field.into(); Self { diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs index 8c4a2e1c..ad512e02 100644 --- a/src-tauri/src/models/editor.rs +++ b/src-tauri/src/models/editor.rs @@ -188,9 +188,30 @@ pub struct EditorCommitRequest { pub mutation_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub gesture_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gesture_ids: Vec, pub changes: EditorPatchV1, } +impl EditorCommitRequest { + pub fn echoed_gesture_ids(&self) -> Vec { + let mut gesture_ids = + Vec::with_capacity(self.gesture_ids.len() + usize::from(self.gesture_id.is_some())); + for gesture_id in self.gesture_ids.iter().chain(self.gesture_id.iter()) { + if !gesture_ids.contains(gesture_id) { + gesture_ids.push(gesture_id.clone()); + } + } + gesture_ids + } + + pub fn history_gesture_id(&self) -> Option { + self.gesture_id + .clone() + .or_else(|| self.gesture_ids.last().cloned()) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorCommitResult { @@ -232,6 +253,8 @@ pub struct EditorCommittedV1 { pub mutation_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub gesture_id: Option, + #[serde(default)] + pub gesture_ids: Vec, pub origin: String, pub changed_fields: Vec, pub patch: EditorPatchV1, diff --git a/src-tauri/src/services/preview_broker.rs b/src-tauri/src/services/preview_broker.rs index 2934c805..350435f5 100644 --- a/src-tauri/src/services/preview_broker.rs +++ b/src-tauri/src/services/preview_broker.rs @@ -113,9 +113,6 @@ pub struct PreviewEnvelope { pub mode: String, pub targets: Vec, pub patch: Map, - // cancel 전용: 수신측이 이 revision 반영 후에만 세션을 제거하는 순서 게이트 - #[serde(default)] - pub min_revision: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -218,7 +215,6 @@ impl PreviewBroker { mode: request.mode, targets: request.targets, patch: request.patch, - min_revision: None, }; validate_payload_size(&envelope)?; @@ -302,12 +298,7 @@ impl PreviewBroker { cancelled } - pub fn cancel( - &self, - label: &str, - session_id: &str, - min_revision: Option, - ) -> Result<(), String> { + pub fn cancel(&self, label: &str, session_id: &str) -> Result<(), String> { validate_session_id(session_id)?; let (recipients, envelope) = { let mut state = self.state.lock(); @@ -339,9 +330,7 @@ impl PreviewBroker { }; insert_tombstone(&mut state, session_id.to_string()); let recipients = clone_channels(&state, Some(label)); - let mut envelope = cancellation_envelope(session_id, &session); - envelope.min_revision = min_revision; - (recipients, envelope) + (recipients, cancellation_envelope(session_id, &session)) }; send_envelopes(&recipients, &[envelope]); @@ -352,12 +341,12 @@ impl PreviewBroker { &self, label: &str, session_id: &str, - committed_revision: Option, + broadcast_cancel: bool, ) -> Result { if Uuid::parse_str(session_id).is_err() { return Ok(false); } - let (recipients, envelope) = { + let (recipients, cancellation) = { let mut state = self.state.lock(); if state.tombstones.contains(session_id) { return Ok(false); @@ -382,14 +371,19 @@ impl PreviewBroker { }, }; insert_tombstone(&mut state, session_id.to_string()); - let recipients = clone_channels(&state, Some(label)); - let mut envelope = cancellation_envelope(session_id, &session); - // 수신측이 커밋 canonical 반영 후에만 세션을 제거하도록 게이트 - envelope.min_revision = committed_revision; - (recipients, envelope) + let recipients = if broadcast_cancel { + clone_channels(&state, Some(label)) + } else { + Vec::new() + }; + let cancellation = + broadcast_cancel.then(|| cancellation_envelope(session_id, &session)); + (recipients, cancellation) }; - send_envelopes(&recipients, &[envelope]); + if let Some(cancellation) = cancellation { + send_envelopes(&recipients, &[cancellation]); + } Ok(true) } @@ -501,7 +495,6 @@ fn cancellation_envelope(session_id: &str, session: &PreviewSession) -> PreviewE mode: String::new(), targets: Vec::new(), patch: Map::new(), - min_revision: None, } } @@ -574,7 +567,6 @@ mod tests { mode: request.mode, targets: request.targets, patch: request.patch, - min_revision: None, } } @@ -756,34 +748,42 @@ mod tests { } #[test] - fn successful_commit_ends_preview_session() { + fn committed_sessions_are_tombstoned_without_auxiliary_broadcast() { let broker = PreviewBroker::default(); subscribe(&broker, "owner"); let observer_messages = subscribe(&broker, "observer"); - let session_id = session_id(); - broker - .publish("owner", request(&session_id, 1)) - .expect("publish succeeds"); + let session_ids = vec![session_id(), session_id()]; + for session_id in &session_ids { + broker + .publish("owner", request(session_id, 1)) + .expect("publish succeeds"); + } - assert!(broker - .finish_committed_session("owner", &session_id, Some(7)) - .expect("commit cleanup succeeds")); + for session_id in &session_ids { + assert!(broker + .finish_committed_session("owner", session_id, false) + .expect("commit cleanup succeeds")); + } assert_eq!(observer_messages.load(Ordering::SeqCst), 2); - assert!(broker - .publish("owner", request(&session_id, 2)) - .unwrap_err() - .contains("already ended")); + for session_id in &session_ids { + assert!(broker + .publish("owner", request(session_id, 2)) + .unwrap_err() + .contains("already ended")); + } } #[test] - fn commit_before_first_publish_rejects_late_patch() { + fn no_op_commit_broadcasts_cancel_and_rejects_late_patch() { let broker = PreviewBroker::default(); subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); let session_id = session_id(); assert!(broker - .finish_committed_session("owner", &session_id, None) + .finish_committed_session("owner", &session_id, true) .expect("commit cleanup succeeds")); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); assert!(broker .publish("owner", request(&session_id, 1)) @@ -798,7 +798,7 @@ mod tests { let session_id = session_id(); broker - .cancel("owner", &session_id, None) + .cancel("owner", &session_id) .expect("early cancel succeeds"); assert!(broker diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index ebf0b796..3443637e 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -20,6 +20,8 @@ pub(crate) const MAX_CUSTOM_TABS: usize = 30; const MAX_MUTATION_ID_BYTES: usize = 64; const MAX_GESTURE_ID_BYTES: usize = 64; +// 프론트 editorCoordinator.ts의 MAX_PENDING_GESTURE_IDS와 동일한 IPC 상한 +const MAX_GESTURE_IDS: usize = 32; const MAX_MODE_ID_BYTES: usize = 128; const MAX_MODES: usize = 64; const MAX_ITEMS_PER_MODE: usize = 512; @@ -44,6 +46,7 @@ pub(crate) type RequestFingerprint = [u8; 32]; struct FingerprintPayload<'a> { base_revision: u64, gesture_id: Option<&'a str>, + gesture_ids: &'a [String], changes: &'a crate::models::EditorPatchV1, } @@ -88,15 +91,27 @@ pub(crate) fn validate_request_envelope( )); } + if request.gesture_ids.len() > MAX_GESTURE_IDS + || request + .gesture_ids + .iter() + .chain(request.gesture_id.iter()) + .collect::>() + .len() + > MAX_GESTURE_IDS + { + return Err(EditorCommitError::too_many_gesture_ids(MAX_GESTURE_IDS)); + } + if request .gesture_id - .as_ref() - .is_some_and(|gesture_id| gesture_id.len() > MAX_GESTURE_ID_BYTES) + .iter() + .chain(request.gesture_ids.iter()) + .any(|gesture_id| { + gesture_id.len() > MAX_GESTURE_ID_BYTES || Uuid::parse_str(gesture_id).is_err() + }) { - return Err(EditorCommitError::validation( - "INVALID_GESTURE_ID", - "gestureId must be no longer than 64 bytes", - )); + return Err(EditorCommitError::invalid_gesture_id()); } Ok(()) @@ -202,6 +217,7 @@ pub(crate) fn request_fingerprint( let value = serde_json::to_value(FingerprintPayload { base_revision: request.base_revision, gesture_id: request.gesture_id.as_deref(), + gesture_ids: &request.gesture_ids, changes: &request.changes, }) .map_err(|error| { @@ -1034,6 +1050,7 @@ mod tests { base_revision: 0, mutation_id: Uuid::new_v4().to_string(), gesture_id: None, + gesture_ids: Vec::new(), changes: EditorPatchV1 { keys: Some(keys), ..EditorPatchV1::default() @@ -1636,6 +1653,7 @@ mod tests { base_revision: 0, mutation_id: "not-a-uuid".to_string(), gesture_id: None, + gesture_ids: Vec::new(), changes: EditorPatchV1::default(), }; assert!(validate_request_envelope(&invalid).is_err()); @@ -1644,6 +1662,37 @@ mod tests { oversized_gesture.gesture_id = Some("가".repeat(22)); assert_eq!(oversized_gesture.gesture_id.as_ref().unwrap().len(), 66); assert!(validate_request_envelope(&oversized_gesture).is_err()); + + let mut oversized_merged_gesture = request(KeyMappings::new()); + oversized_merged_gesture.gesture_ids = vec!["가".repeat(22)]; + assert!(validate_request_envelope(&oversized_merged_gesture).is_err()); + + let mut malformed_gesture = request(KeyMappings::new()); + malformed_gesture.gesture_ids = vec!["not-a-uuid".to_string()]; + let malformed_error = validate_request_envelope(&malformed_gesture).unwrap_err(); + assert_eq!( + malformed_error.error_code, + crate::errors::EditorCommitErrorCode::InvalidGestureId + ); + assert!(!malformed_error.retryable); + + let mut too_many_gestures = request(KeyMappings::new()); + too_many_gestures.gesture_ids = (0..=MAX_GESTURE_IDS) + .map(|index| Uuid::from_u128(index as u128 + 1).to_string()) + .collect(); + let count_error = validate_request_envelope(&too_many_gestures).unwrap_err(); + assert_eq!( + count_error.error_code, + crate::errors::EditorCommitErrorCode::TooManyGestureIds + ); + assert!(!count_error.retryable); + + let mut representative_overflow = request(KeyMappings::new()); + representative_overflow.gesture_ids = (0..MAX_GESTURE_IDS) + .map(|index| Uuid::from_u128(index as u128 + 1).to_string()) + .collect(); + representative_overflow.gesture_id = Some(Uuid::from_u128(u128::MAX).to_string()); + assert!(validate_request_envelope(&representative_overflow).is_err()); } #[test] diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index faf26c54..e7dcf704 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -175,6 +175,7 @@ struct PluginInstancesMutationInput { struct EditorPatchCommitOptions { mutation_id: String, gesture_id: Option, + gesture_ids: Vec, origin: EditorCommitOrigin, record_history: bool, apply_key_side_effects: bool, @@ -515,6 +516,7 @@ impl AppStore { EditorPatchCommitOptions { mutation_id: operation_id.to_string(), gesture_id: None, + gesture_ids: Vec::new(), origin, record_history: false, apply_key_side_effects: false, @@ -764,6 +766,8 @@ impl AppStore { )); } + let gesture_id = request.history_gesture_id(); + let gesture_ids = request.echoed_gesture_ids(); let touched_fields = request.changes.included_fields(); let change = self.commit_editor_patch_locked( &mut guard, @@ -771,7 +775,8 @@ impl AppStore { &touched_fields, EditorPatchCommitOptions { mutation_id: request.mutation_id.clone(), - gesture_id: request.gesture_id, + gesture_id, + gesture_ids, origin: EditorCommitOrigin::StrictEditorCommit, record_history: true, apply_key_side_effects: true, @@ -875,6 +880,7 @@ impl AppStore { revision, mutation_id: options.mutation_id, gesture_id: options.gesture_id, + gesture_ids: options.gesture_ids, origin, changed_fields: changed_fields.clone(), patch: candidate.patch_for_fields(&changed_fields), @@ -949,6 +955,7 @@ impl AppStore { revision, mutation_id: operation_id.to_string(), gesture_id: None, + gesture_ids: Vec::new(), origin, changed_fields: changed_fields.clone(), patch: candidate.patch_for_fields(&changed_fields), @@ -1020,6 +1027,7 @@ impl AppStore { revision, mutation_id: operation_id.to_string(), gesture_id: None, + gesture_ids: Vec::new(), origin, changed_fields: changed_fields.clone(), patch: candidate.patch_for_fields(&changed_fields), @@ -1294,6 +1302,7 @@ impl AppStore { revision, mutation_id: uuid::Uuid::new_v4().to_string(), gesture_id: None, + gesture_ids: Vec::new(), origin, changed_fields: changed_fields.clone(), patch: candidate.patch_for_fields(&changed_fields), @@ -2214,6 +2223,8 @@ fn editor_error_outcome(code: EditorCommitErrorCode) -> &'static str { match code { EditorCommitErrorCode::RevisionConflict => "revision_conflict", EditorCommitErrorCode::ValidationFailed => "validation_failed", + EditorCommitErrorCode::TooManyGestureIds => "too_many_gesture_ids", + EditorCommitErrorCode::InvalidGestureId => "invalid_gesture_id", EditorCommitErrorCode::PairedUpdateRequired => "paired_update_required", EditorCommitErrorCode::MutationIdReused => "mutation_id_reused", EditorCommitErrorCode::HistoryInProgress => "history_in_progress", @@ -3493,6 +3504,7 @@ mod tests { base_revision, mutation_id: mutation_id.into(), gesture_id: None, + gesture_ids: Vec::new(), changes, } } @@ -4240,6 +4252,7 @@ mod tests { let old_key = before.keys["4key"][0].clone(); let mut keys = before.keys.clone(); keys.get_mut("4key").unwrap()[0] = "StrictKey".to_string(); + let first_gesture_id = uuid::Uuid::new_v4().to_string(); let gesture_id = uuid::Uuid::new_v4().to_string(); let mut request = editor_request( 0, @@ -4250,6 +4263,11 @@ mod tests { }, ); request.gesture_id = Some(gesture_id.clone()); + request.gesture_ids = vec![ + first_gesture_id.clone(), + first_gesture_id.clone(), + gesture_id.clone(), + ]; let change = store.commit_editor_document(request).unwrap(); @@ -4260,6 +4278,10 @@ mod tests { change.event.as_ref().unwrap().gesture_id.as_deref(), Some(gesture_id.as_str()) ); + assert_eq!( + change.event.as_ref().unwrap().gesture_ids, + vec![first_gesture_id, gesture_id] + ); assert_eq!(store.writer.persist_count(), persist_count + 1); let snapshot = store.snapshot(); assert_eq!(snapshot.editor_revision, 1); diff --git a/src/renderer/__tests__/previewOverlaySession.test.ts b/src/renderer/__tests__/previewOverlaySession.test.ts index d3c440b3..160edb39 100644 --- a/src/renderer/__tests__/previewOverlaySession.test.ts +++ b/src/renderer/__tests__/previewOverlaySession.test.ts @@ -7,7 +7,6 @@ import { useStatItemStore } from '@stores/data/useStatItemStore'; import { composePreviewPositions, previewOverlay, - registerPreviewRevisionProbe, } from '@src/renderer/editor/runtime/previewOverlay'; import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; import { previewApi } from '@api/modules/previewApi'; @@ -175,54 +174,32 @@ describe('previewOverlay', () => { ).toBeUndefined(); }); - it('minRevision 게이트에 걸린 cancel은 revision 전진 후에만 처리', () => { - let currentRevision: number | null = 3; - registerPreviewRevisionProbe(() => currentRevision); - - previewOverlay.applyRemoteEnvelope(remoteEnvelope({ seq: 1 })); + it('병합 커밋은 두 창의 세션을 함께 끝내고 늦은 patch를 차단', () => { + previewOverlay.applyLocalPatch('main-session', '4key', [0], { width: 90 }); previewOverlay.applyRemoteEnvelope( remoteEnvelope({ - seq: 2, - kind: 'cancel', - targets: [], - patch: {}, - minRevision: 5, + sessionId: 'panel-session', + targets: [1], + patch: { width: 95 }, }), ); - // 아직 revision 미도달이라 프리뷰 유지 - expect(useKeyStore.getState().positions['4key'][0].backgroundColor).toBe( - '#ff0000', - ); - - currentRevision = 5; - previewOverlay.flushDeferredCancels(5); - expect( - useKeyStore.getState().positions['4key'][0].backgroundColor, - ).toBeUndefined(); - - registerPreviewRevisionProbe(null); - }); + previewOverlay.endSessions(['main-session', 'panel-session']); - it('revision이 이미 도달한 cancel은 즉시 처리', () => { - registerPreviewRevisionProbe(() => 10); + expect(useKeyStore.getState().positions['4key'][0].width).toBe(60); + expect(useKeyStore.getState().positions['4key'][1].width).toBe(60); - previewOverlay.applyRemoteEnvelope(remoteEnvelope({ seq: 1 })); + previewOverlay.applyLocalPatch('main-session', '4key', [0], { width: 100 }); previewOverlay.applyRemoteEnvelope( remoteEnvelope({ + sessionId: 'panel-session', seq: 2, - kind: 'cancel', - targets: [], - patch: {}, - minRevision: 5, + targets: [1], + patch: { width: 105 }, }), ); - - expect( - useKeyStore.getState().positions['4key'][0].backgroundColor, - ).toBeUndefined(); - - registerPreviewRevisionProbe(null); + expect(useKeyStore.getState().positions['4key'][0].width).toBe(60); + expect(useKeyStore.getState().positions['4key'][1].width).toBe(60); }); it('프리뷰 활성 중 canonical 편집이 들어와도 오버레이가 재합성됨', () => { @@ -325,7 +302,7 @@ describe('editGestureController', () => { expect(request.patch).toEqual({ backgroundColor: '#111111' }); }); - it('settleCommit 성공 시 세션 종료 + 수신측 정리 브로드캐스트', async () => { + it('settleCommit 성공 시 로컬 세션 종료 + 보조 cancel 브로드캐스트', async () => { editGestureController.preview('4key', [ { index: 0, patch: { width: 100 } }, ]); @@ -335,8 +312,10 @@ describe('editGestureController', () => { await flushPromises(); expect(editGestureController.hasActiveGesture()).toBe(false); - // 커밋 revision 미확정(null) 상태에서는 게이트 없이 보조 cancel 발송 - expect(previewApi.cancel).toHaveBeenCalledWith(sessionId, undefined); + // 배치 간격 커밋처럼 committed echo가 다른 gestureId로 향하면 + // 원격 창 세션이 잔존하므로 성공 경로에도 보조 cancel이 나가야 함 + expect(previewApi.cancel).toHaveBeenCalledTimes(1); + expect(previewApi.cancel).toHaveBeenCalledWith(sessionId); expect(useKeyStore.getState().positions['4key'][0].width).toBe(60); }); diff --git a/src/renderer/api/modules/editorAdapters.test.ts b/src/renderer/api/modules/editorAdapters.test.ts index db64ac76..19ee82e2 100644 --- a/src/renderer/api/modules/editorAdapters.test.ts +++ b/src/renderer/api/modules/editorAdapters.test.ts @@ -68,10 +68,13 @@ describe('editor API compatibility adapters', () => { commitPatch.mockClear(); commitPatch.mockResolvedValue(structuredClone(document)); - await keysApi.updatePositions(positions, 'gesture-1'); + await keysApi.updatePositions( + positions, + '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50', + ); expect(commitPatch).toHaveBeenCalledWith( { schemaVersion: 1, keyPositions: positions }, - { gestureId: 'gesture-1' }, + { gestureId: '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50' }, ); }); diff --git a/src/renderer/api/modules/keysApi.ts b/src/renderer/api/modules/keysApi.ts index 74662978..e03351b7 100644 --- a/src/renderer/api/modules/keysApi.ts +++ b/src/renderer/api/modules/keysApi.ts @@ -24,6 +24,24 @@ import type { KeyCounters, } from '@src/types/key/keys'; +// 백엔드 gestureId UUID 강제와 동형 (canonical hyphenated + 32-hex simple) +const GESTURE_ID_PATTERN = + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32})$/i; + +// 플러그인 표면(dmn.keys.*)이 임의 문자열을 넘길 수 있음 - 비 UUID가 드레인에 +// 섞이면 같은 커밋의 사용자 편집까지 INVALID_GESTURE_ID로 통째 실패하므로 +// 유입 지점에서 걸러내고 커밋은 진행 +const sanitizeGestureId = (gestureId?: string): string | undefined => { + if (!gestureId || GESTURE_ID_PATTERN.test(gestureId)) return gestureId; + console.warn('[keysApi] Dropped non-UUID gestureId:', gestureId); + return undefined; +}; + +const gestureMeta = (gestureId?: string): { gestureId: string } | undefined => { + const safeId = sanitizeGestureId(gestureId); + return safeId ? { gestureId: safeId } : undefined; +}; + export const keysApi = { get: () => invoke('keys_get'), getCounters: () => invoke('keys_get_counters'), @@ -45,7 +63,7 @@ export const keysApi = { keys: mappings, keyPositions: positions, }, - gestureId ? { gestureId } : undefined, + gestureMeta(gestureId), ), () => ({ keys: structuredClone(mappings), @@ -61,7 +79,7 @@ export const keysApi = { schemaVersion: 1, keyPositions: positions, }, - gestureId ? { gestureId } : undefined, + gestureMeta(gestureId), ), () => structuredClone(positions), ), diff --git a/src/renderer/api/modules/previewApi.ts b/src/renderer/api/modules/previewApi.ts index dd3f6c5f..a5211d5f 100644 --- a/src/renderer/api/modules/previewApi.ts +++ b/src/renderer/api/modules/previewApi.ts @@ -26,6 +26,6 @@ export const previewApi = { publish: (request: PreviewPublishRequest) => invoke('editor_preview_publish', { request }), - cancel: (sessionId: string, minRevision?: number) => - invoke('editor_preview_cancel', { sessionId, minRevision }), + cancel: (sessionId: string) => + invoke('editor_preview_cancel', { sessionId }), }; diff --git a/src/renderer/editor/runtime/editGestureController.ts b/src/renderer/editor/runtime/editGestureController.ts index 6bd15678..6905c93b 100644 --- a/src/renderer/editor/runtime/editGestureController.ts +++ b/src/renderer/editor/runtime/editGestureController.ts @@ -16,6 +16,11 @@ import { PREVIEW_SCHEMA_VERSION, type PreviewDomain } from '@src/types/preview'; import { previewOverlay } from './previewOverlay'; import { editorCoordinator } from './editorStateCoordinator'; import { drainEditorWrites, trackEditorWrite } from './editorWriteBarrier'; +import { + registerGestureSession, + releaseGestureSession, + type GestureSessionLifecycle, +} from './gestureSessionLifecycle'; interface PreviewEntry { index: number; @@ -28,6 +33,7 @@ interface PreviewOptions { interface ActiveGesture { sessionId: string; + lifecycle: GestureSessionLifecycle; mode: string; seq: number; // 도메인 → target index → 게스처 동안 누적된 전체 patch @@ -105,8 +111,10 @@ export const editGestureController = { this.cancel(); } if (!active) { + const sessionId = crypto.randomUUID(); active = { - sessionId: crypto.randomUUID(), + sessionId, + lifecycle: registerGestureSession(sessionId), mode, seq: 0, appliedPatches: new Map(), @@ -159,10 +167,7 @@ export const editGestureController = { return active?.sessionId ?? null; }, - /** - * 커밋 정산: persist 성공 시 세션 종료 + 수신측 정리 브로드캐스트 - * 실패 시 세션 유지 (재시도 또는 명시 cancel은 호출자 몫) - */ + /** 커밋 정산: 성공 시 로컬 세션 종료, 실패 시 재시도 상태 복원 */ settleCommit(persistPromise: Promise): void { // 게스처 유무와 무관하게 창 전환이 정산 커밋의 성패까지 기다리게 함 trackEditorWrite(persistPromise); @@ -173,21 +178,26 @@ export const editGestureController = { persistPromise .then(() => { + releaseGestureSession(gesture.lifecycle); previewOverlay.endSession(gesture.sessionId); - // 1차 정리는 committed 이벤트의 gestureId echo, cancel은 병합 커밋 등 - // echo가 다른 세션으로 향한 경우를 위한 보조 정리 - // 커밋 revision을 게이트로 실어 수신측의 선행 제거를 방지 - const committedRevision = editorCoordinator.getState().revision; - previewApi - .cancel(gesture.sessionId, committedRevision ?? undefined) - .catch(() => {}); + // 1차 정리는 committed 이벤트의 gestureIds echo - 배치 간격 커밋처럼 + // echo가 다른 세션 ID로 향하면 원격 창의 이 세션이 잔존하므로 보조 정리 + // (tombstone이 중복 cancel을 흡수해 정상 경로에선 no-op) + previewApi.cancel(gesture.sessionId).catch(() => {}); }) .catch((error) => { + if (gesture.lifecycle.discarded) { + releaseGestureSession(gesture.lifecycle); + previewOverlay.endSession(gesture.sessionId); + previewApi.cancel(gesture.sessionId).catch(() => {}); + return; + } console.error('Commit failed, keeping preview session', error); // 새 게스처가 이미 시작됐으면 이전 세션은 오버레이만 정리 if (active === null) { active = gesture; } else { + releaseGestureSession(gesture.lifecycle); previewOverlay.endSession(gesture.sessionId); previewApi.cancel(gesture.sessionId).catch(() => {}); } @@ -199,6 +209,7 @@ export const editGestureController = { const gesture = active; if (!gesture) return; active = null; + releaseGestureSession(gesture.lifecycle); gesture.pendingGroups.clear(); previewOverlay.endSession(gesture.sessionId); previewApi.cancel(gesture.sessionId).catch(() => {}); diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index 16e0d5b5..8a7dcecc 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -5,6 +5,7 @@ import { EditorProtocolError, assertEditorGetResult, assertSafeEditorRevision, + isEditorCommitError, } from '@src/types/editor'; import { @@ -153,7 +154,12 @@ const createHarness = ( options: Partial< Pick< EditorCoordinatorOptions, - 'focusTarget' | 'visibilityTarget' | 'readOnly' + | 'focusTarget' + | 'visibilityTarget' + | 'readOnly' + | 'onCommittedApplied' + | 'onGestureIdsDiscarded' + | 'onStartSucceeded' > > = {}, ) => { @@ -176,6 +182,9 @@ const createHarness = ( focusTarget: options.focusTarget ?? null, visibilityTarget: options.visibilityTarget ?? null, readOnly: options.readOnly, + onCommittedApplied: options.onCommittedApplied, + onGestureIdsDiscarded: options.onGestureIdsDiscarded, + onStartSucceeded: options.onStartSucceeded, }); return { @@ -268,6 +277,19 @@ describe('editor document helpers', () => { expect(() => assertEditorGetResult(result)).not.toThrow(); }); + it.each(['TOO_MANY_GESTURE_IDS', 'INVALID_GESTURE_ID'] as const)( + 'recognizes %s as a non-retryable editor commit error', + (errorCode) => { + expect( + isEditorCommitError({ + errorCode, + message: errorCode, + retryable: false, + }), + ).toBe(true); + }, + ); + it('still rejects null for required position fields', () => { const result = { revision: 0, @@ -327,6 +349,25 @@ describe('editor document helpers', () => { }); describe('EditorSaveCoordinator', () => { + it('runs the start success hook after initialization recovers lazily', async () => { + const initializationError = new Error('initial get failed'); + const onStartSucceeded = vi.fn(async () => {}); + const harness = createHarness(makeDocument(), { onStartSucceeded }); + harness.transport.getMock.mockRejectedValueOnce(initializationError); + + await expect(harness.coordinator.start()).rejects.toBe(initializationError); + expect(onStartSucceeded).not.toHaveBeenCalled(); + + await expect(harness.coordinator.start()).resolves.toMatchObject({ + revision: 0, + }); + expect(onStartSucceeded).toHaveBeenCalledOnce(); + + await harness.coordinator.start(); + expect(onStartSucceeded).toHaveBeenCalledTimes(2); + harness.coordinator.stop(); + }); + it('buffers events until the initial snapshot closes the subscription race', async () => { const base = makeDocument(); const next = { ...base, keys: { '4key': ['B'] } }; @@ -438,7 +479,7 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); - it('merges same-tick patches without dropping intermediate collections', async () => { + it('merges same-tick patches with every gesture ID', async () => { const base = makeDocument(); const keyPositions = structuredClone(base.keyPositions); keyPositions['4key'][0].dx = 10; @@ -461,10 +502,22 @@ describe('EditorSaveCoordinator', () => { await harness.coordinator.start(); const commits = [ - harness.coordinator.commitPatch({ schemaVersion: 1, keyPositions }), - harness.coordinator.commitPatch({ schemaVersion: 1, statPositions }), - harness.coordinator.commitPatch({ schemaVersion: 1, graphPositions }), - harness.coordinator.commitPatch({ schemaVersion: 1, knobPositions }), + harness.coordinator.commitPatch( + { schemaVersion: 1, keyPositions }, + { gestureId: 'gesture-key' }, + ), + harness.coordinator.commitPatch( + { schemaVersion: 1, statPositions }, + { gestureId: 'gesture-stat' }, + ), + harness.coordinator.commitPatch( + { schemaVersion: 1, graphPositions }, + { gestureId: 'gesture-graph' }, + ), + harness.coordinator.commitPatch( + { schemaVersion: 1, knobPositions }, + { gestureId: 'gesture-knob' }, + ), ]; await vi.waitFor(() => expect(harness.transport.commitMock).toHaveBeenCalledOnce(), @@ -480,6 +533,14 @@ describe('EditorSaveCoordinator', () => { graphPositions, knobPositions, }); + expect(harness.transport.commitMock.mock.calls[0][0]).toMatchObject({ + gestureId: 'gesture-key', + gestureIds: ['gesture-key'], + }); + expect(harness.transport.commitMock.mock.calls[1][0]).toMatchObject({ + gestureId: 'gesture-knob', + gestureIds: ['gesture-stat', 'gesture-graph', 'gesture-knob'], + }); mergedResult.resolve({ revision: 2, @@ -496,6 +557,67 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); + it('keeps the newest 32 gesture IDs across an IO failure and retry', async () => { + const base = makeDocument(); + const onGestureIdsDiscarded = + vi.fn<(gestureIds: readonly string[]) => void>(); + const harness = createHarness(base, { onGestureIdsDiscarded }); + const firstResult = deferred(); + harness.transport.commitMock.mockReturnValueOnce(firstResult.promise); + const gestureIds = Array.from( + { length: 34 }, + (_, index) => + `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + ); + await harness.coordinator.start(); + + const commits = [ + harness.coordinator.commitPatch( + { schemaVersion: 1, keys: { '4key': ['value-0'] } }, + { gestureId: gestureIds[0] }, + ), + ]; + await vi.waitFor(() => + expect(harness.transport.commitMock).toHaveBeenCalledOnce(), + ); + + for (let index = 1; index < gestureIds.length; index += 1) { + commits.push( + harness.coordinator.commitPatch( + { schemaVersion: 1, keys: { '4key': [`value-${index}`] } }, + { gestureId: gestureIds[index] }, + ), + ); + } + await vi.waitFor(() => + expect(onGestureIdsDiscarded).toHaveBeenCalledOnce(), + ); + + const transientError = ioError(); + firstResult.reject(transientError); + const results = await Promise.allSettled(commits); + expect(results.every(({ status }) => status === 'rejected')).toBe(true); + expect(harness.coordinator.getState()).toMatchObject({ + dirty: true, + failureKind: 'transient', + }); + + await expect(harness.coordinator.retryPending()).resolves.toMatchObject({ + keys: { '4key': ['value-33'] }, + }); + expect(harness.transport.commitMock).toHaveBeenCalledTimes(2); + const retryRequest = harness.transport.commitMock.mock.calls[1][0]; + expect(retryRequest.gestureIds).toEqual(gestureIds.slice(-32)); + expect(retryRequest.gestureIds).toHaveLength(32); + expect(retryRequest.gestureId).toBe(gestureIds.at(-1)); + expect(retryRequest.gestureIds).toContain(retryRequest.gestureId); + expect(onGestureIdsDiscarded.mock.calls.flatMap(([ids]) => ids)).toEqual([ + gestureIds[1], + gestureIds[0], + ]); + harness.coordinator.stop(); + }); + it('does not apply its own event twice when it arrives before the response', async () => { const base = makeDocument(); const target = { ...base, keys: { '4key': ['B'] } }; @@ -524,6 +646,31 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); + it('응답 뒤에 도착한 own event도 병합 gesture ID 정리를 전달', async () => { + const base = makeDocument(); + const target = { ...base, keys: { '4key': ['B'] } }; + const onCommittedApplied = vi.fn(); + const harness = createHarness(base, { onCommittedApplied }); + await harness.coordinator.start(); + + await harness.coordinator.commitPatch({ + schemaVersion: 1, + keys: target.keys, + }); + const request = harness.transport.commitMock.mock.calls[0][0]; + harness.transport.emit({ + ...eventFor(1, request.mutationId, base, target), + gestureIds: ['main-session', 'panel-session'], + }); + + await vi.waitFor(() => expect(onCommittedApplied).toHaveBeenCalledOnce()); + expect(onCommittedApplied.mock.calls[0][0].gestureIds).toEqual([ + 'main-session', + 'panel-session', + ]); + harness.coordinator.stop(); + }); + it('resynchronizes a revision gap and ignores an older event', async () => { const base = makeDocument(); const remote = withGroups({ ...base, keys: { '4key': ['C'] } }, 'remote'); @@ -545,6 +692,31 @@ describe('EditorSaveCoordinator', () => { harness.coordinator.stop(); }); + it('revision gap 재동기화 실패에도 committed 프리뷰 정리를 전달', async () => { + const base = makeDocument(); + const remote = withGroups({ ...base, keys: { '4key': ['C'] } }, 'remote'); + const onCommittedApplied = vi.fn(); + const harness = createHarness(base, { onCommittedApplied }); + await harness.coordinator.start(); + const resyncError = new Error('resync unavailable'); + harness.transport.getMock.mockRejectedValueOnce(resyncError); + + harness.transport.emit({ + ...eventFor(2, 'external-gap', base, remote), + gestureIds: ['00000000-0000-4000-8000-000000000001'], + }); + + await vi.waitFor(() => expect(onCommittedApplied).toHaveBeenCalledOnce()); + expect(onCommittedApplied.mock.calls[0][0].gestureIds).toEqual([ + '00000000-0000-4000-8000-000000000001', + ]); + await vi.waitFor(() => + expect(harness.coordinator.getState().error).toBe(resyncError), + ); + expect(harness.coordinator.getState().revision).toBe(0); + harness.coordinator.stop(); + }); + it('revalidates on focus and visible lifecycle transitions', async () => { class VisibilityTarget extends EventTarget { visibilityState = 'visible'; diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 31aa0383..95fccfce 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -95,8 +95,8 @@ export interface EditorCoordinatorOptions { readOnly?: boolean | (() => boolean); // committed 이벤트가 canonical에 반영된 직후 호출 (프리뷰 오버레이 정리용) onCommittedApplied?: (event: EditorCommittedV1) => void; - // committed·resync 등 canonical revision이 전진한 모든 경로에서 호출 - onCanonicalRevisionAdvanced?: (revision: number) => void; + onGestureIdsDiscarded?: (gestureIds: readonly string[]) => void; + onStartSucceeded?: () => void | Promise; } export interface EditorSyncOptions { @@ -117,11 +117,13 @@ interface InFlightCommit { target: EditorDocumentV1; localFields: EditorField[]; requestFields: EditorField[]; - gestureId?: string; + gestureIds: string[]; } const MAX_AUTO_REBASE_ATTEMPTS = 2; const MAX_TRACKED_MUTATIONS = 64; +// Rust state/editor.rs의 MAX_GESTURE_IDS와 동일한 IPC 상한 +const MAX_PENDING_GESTURE_IDS = 32; const clone = (value: T): T => structuredClone(value); @@ -210,9 +212,10 @@ export class EditorSaveCoordinator { private readonly onCommittedApplied: | ((event: EditorCommittedV1) => void) | null; - private readonly onCanonicalRevisionAdvanced: - | ((revision: number) => void) + private readonly onGestureIdsDiscarded: + | ((gestureIds: readonly string[]) => void) | null; + private readonly onStartSucceeded: (() => void | Promise) | null; private phase: EditorCoordinatorPhase = 'idle'; private revision: number | null = null; @@ -221,8 +224,8 @@ export class EditorSaveCoordinator { private pendingFields: EditorField[] = []; private pendingRequestFields: EditorField[] = []; private inFlight: InFlightCommit | null = null; - // 커밋 의도 시점에 캡처된 게스처 ID (드레인에서 소비, 실패 시 복원) - private pendingGestureId: string | null = null; + // 커밋 의도 시점에 캡처된 게스처 ID 집합 + private pendingGestureIds: string[] = []; private conflict: EditorConflictState | null = null; private error: unknown = null; private failureKind: 'transient' | 'permanent' | null = null; @@ -253,8 +256,8 @@ export class EditorSaveCoordinator { this.readDocument = options.readDocument; this.applyDocument = options.applyDocument; this.onCommittedApplied = options.onCommittedApplied ?? null; - this.onCanonicalRevisionAdvanced = - options.onCanonicalRevisionAdvanced ?? null; + this.onGestureIdsDiscarded = options.onGestureIdsDiscarded ?? null; + this.onStartSucceeded = options.onStartSucceeded ?? null; this.createMutationId = options.createMutationId ?? (() => crypto.randomUUID()); this.focusTarget = @@ -298,20 +301,23 @@ export class EditorSaveCoordinator { if (this.stopped) { return Promise.reject(new Error('editor coordinator is stopped')); } - if (this.startPromise) return this.startPromise; - - this.startPromise = this.initialize().catch((error) => { - this.startPromise = null; - this.initializing = false; - this.phase = 'error'; - this.error = error; - this.detachLifecycle(); - this.unsubscribeCommitted?.(); - this.unsubscribeCommitted = null; - this.notify(); - throw error; + if (!this.startPromise) { + this.startPromise = this.initialize().catch((error) => { + this.startPromise = null; + this.initializing = false; + this.phase = 'error'; + this.error = error; + this.detachLifecycle(); + this.unsubscribeCommitted?.(); + this.unsubscribeCommitted = null; + this.notify(); + throw error; + }); + } + return this.startPromise.then(async (result) => { + await this.onStartSucceeded?.(); + return result; }); - return this.startPromise; } async commitEditorState( @@ -337,8 +343,9 @@ export class EditorSaveCoordinator { requestFields: readonly EditorField[], gestureId?: string, ): Promise { - // 병합 시 마지막 게스처가 대표 ID, 나머지는 정산 측 cancel 브로드캐스트가 정리 - if (gestureId) this.pendingGestureId = gestureId; + if (gestureId) { + this.replacePendingGestureIds([...this.pendingGestureIds, gestureId]); + } if (this.conflict) { const conflict = this.conflict; const newlyChangedFields = getChangedEditorFields( @@ -450,7 +457,7 @@ export class EditorSaveCoordinator { this.pendingLocal = null; this.pendingFields = []; this.pendingRequestFields = []; - this.pendingGestureId = null; + this.pendingGestureIds = []; this.phase = 'idle'; this.applyDocument(clone(conflict.canonical), 'acceptCanonical'); this.notify(); @@ -606,8 +613,9 @@ export class EditorSaveCoordinator { const baseDocument = clone(this.requireLastAck()); const baseRevision = this.requireRevision(); const mutationId = this.createMutationId(); - const gestureId = this.pendingGestureId ?? undefined; - this.pendingGestureId = null; + const gestureIds = this.pendingGestureIds; + this.pendingGestureIds = []; + const gestureId = gestureIds.at(-1); const inFlight: InFlightCommit = { mutationId, baseRevision, @@ -615,7 +623,7 @@ export class EditorSaveCoordinator { target: clone(target), localFields, requestFields, - gestureId, + gestureIds, }; this.inFlight = inFlight; this.rememberOwnMutation(inFlight); @@ -627,7 +635,8 @@ export class EditorSaveCoordinator { baseRevision, mutationId, changes: patchForFields(target, requestFields), - ...(inFlight.gestureId ? { gestureId: inFlight.gestureId } : {}), + ...(gestureId ? { gestureId } : {}), + ...(gestureIds.length > 0 ? { gestureIds } : {}), }); assertEditorCommitResult(result); await this.applyCommitResult(inFlight, result); @@ -652,9 +661,7 @@ export class EditorSaveCoordinator { inFlight.localFields, inFlight.requestFields, ); - if (inFlight.gestureId && this.pendingGestureId === null) { - this.pendingGestureId = inFlight.gestureId; - } + this.restorePendingGestureIds(inFlight.gestureIds); this.phase = 'error'; this.error = syncError; this.failureKind = 'transient'; @@ -663,10 +670,7 @@ export class EditorSaveCoordinator { } if (didRebase) { rebaseAttempts += 1; - // 더 최신 pending 게스처가 없을 때만 실패분 복원 - if (inFlight.gestureId && this.pendingGestureId === null) { - this.pendingGestureId = inFlight.gestureId; - } + this.restorePendingGestureIds(inFlight.gestureIds); continue; } } else if (isEditorCommitError(error) && error.retryable) { @@ -675,9 +679,7 @@ export class EditorSaveCoordinator { inFlight.localFields, inFlight.requestFields, ); - if (inFlight.gestureId && this.pendingGestureId === null) { - this.pendingGestureId = inFlight.gestureId; - } + this.restorePendingGestureIds(inFlight.gestureIds); this.phase = 'error'; this.error = error; this.failureKind = 'transient'; @@ -807,9 +809,18 @@ export class EditorSaveCoordinator { assertEditorCommittedEvent(event); if (this.stopped || this.revision === null || !this.lastAck) return; - if (event.revision <= this.revision) return; + if (event.revision <= this.revision) { + this.ownMutations.delete(event.mutationId); + this.onCommittedApplied?.(event); + return; + } if (event.revision > this.revision + 1) { - await this.fetchAndApplyCanonical('resync'); + try { + await this.fetchAndApplyCanonical('resync'); + } finally { + // 프리뷰 정리는 canonical 재동기화 성공 여부와 독립 + this.onCommittedApplied?.(event); + } return; } @@ -824,7 +835,6 @@ export class EditorSaveCoordinator { this.error = null; this.notify(); this.onCommittedApplied?.(event); - this.onCanonicalRevisionAdvanced?.(event.revision); return; } @@ -836,7 +846,6 @@ export class EditorSaveCoordinator { previousCanonical, ); this.onCommittedApplied?.(event); - this.onCanonicalRevisionAdvanced?.(event.revision); } private async fetchAndApplyCanonical( @@ -855,7 +864,6 @@ export class EditorSaveCoordinator { if (!previous) { this.applyDocument(clone(result.document), reason); this.notify(); - this.onCanonicalRevisionAdvanced?.(result.revision); return; } @@ -867,7 +875,6 @@ export class EditorSaveCoordinator { reason, previous, ); - this.onCanonicalRevisionAdvanced?.(result.revision); } private applyExternalCanonical( @@ -977,12 +984,38 @@ export class EditorSaveCoordinator { ); } + private restorePendingGestureIds(gestureIds: readonly string[]): void { + this.replacePendingGestureIds([...gestureIds, ...this.pendingGestureIds]); + } + + private replacePendingGestureIds(gestureIds: readonly string[]): void { + const seen = new Set(); + const retainedNewestFirst: string[] = []; + const discardedNewestFirst: string[] = []; + + for (let index = gestureIds.length - 1; index >= 0; index -= 1) { + const gestureId = gestureIds[index]; + if (seen.has(gestureId)) continue; + seen.add(gestureId); + if (retainedNewestFirst.length < MAX_PENDING_GESTURE_IDS) { + retainedNewestFirst.push(gestureId); + } else { + discardedNewestFirst.push(gestureId); + } + } + + this.pendingGestureIds = retainedNewestFirst.reverse(); + if (discardedNewestFirst.length > 0) { + this.onGestureIdsDiscarded?.(discardedNewestFirst.reverse()); + } + } + private discardRejectedPending(error: unknown, mutationId: string): void { this.ownMutations.delete(mutationId); this.pendingLocal = null; this.pendingFields = []; this.pendingRequestFields = []; - this.pendingGestureId = null; + this.pendingGestureIds = []; this.phase = 'error'; this.error = error; this.failureKind = 'permanent'; diff --git a/src/renderer/editor/runtime/editorGestureDiscardIntegration.test.ts b/src/renderer/editor/runtime/editorGestureDiscardIntegration.test.ts new file mode 100644 index 00000000..606fcf1f --- /dev/null +++ b/src/renderer/editor/runtime/editorGestureDiscardIntegration.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; + +import type { EditorCommitError, EditorDocumentV1 } from '@src/types/editor'; + +const runtime = vi.hoisted(() => { + let rejectCommit!: (error: EditorCommitError) => void; + const pendingCommit = new Promise((_, reject) => { + rejectCommit = reject; + }); + const get = vi.fn(); + const commit = vi.fn(() => pendingCommit); + const onCommitted = vi.fn(() => + Object.assign(() => {}, { ready: Promise.resolve() }), + ); + const subscribe = vi.fn(async () => 1); + const publish = vi.fn(async () => {}); + const cancel = vi.fn(async () => {}); + + return { + cancel, + commit, + get, + onCommitted, + publish, + rejectCommit: (error: EditorCommitError) => rejectCommit(error), + subscribe, + }; +}); + +vi.mock('@api/modules/editorApi', () => ({ + editorApi: { + get: runtime.get, + commit: runtime.commit, + onCommitted: runtime.onCommitted, + }, +})); + +vi.mock('@api/modules/previewApi', () => ({ + previewApi: { + cancel: runtime.cancel, + publish: runtime.publish, + subscribe: runtime.subscribe, + }, +})); + +import { editGestureController } from './editGestureController'; +import { editorCoordinator } from './editorStateCoordinator'; +import { previewOverlay } from './previewOverlay'; + +const makeDocument = (): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: { '4key': ['A'] }, + keyPositions: { '4key': [createDefaultKeyPosition()] }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: {}, +}); + +const ioError = (): EditorCommitError => ({ + errorCode: 'IO_ERROR', + message: 'disk unavailable', + retryable: true, +}); + +describe('discarded editor gesture integration', () => { + afterEach(() => { + editGestureController.cancel(); + editorCoordinator.stop(); + previewOverlay.clearAll(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('keeps truncated sessions terminal after shared IO failure; old code revived the first session', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + const document = makeDocument(); + runtime.get.mockResolvedValue({ revision: 0, document }); + await editorCoordinator.start(); + + const sessionIds: string[] = []; + const commits: Array> = []; + const queueGesture = (index: number) => { + const width = 61 + index; + editGestureController.preview('4key', [{ index: 0, patch: { width } }]); + const sessionId = editGestureController.activeGestureId(); + expect(sessionId).not.toBeNull(); + sessionIds.push(sessionId!); + const persisted = editorCoordinator.commitPatch( + { + schemaVersion: 1, + keyPositions: { + '4key': [{ ...createDefaultKeyPosition(), width }], + }, + }, + { gestureId: sessionId! }, + ); + editGestureController.settleCommit(persisted); + commits.push(persisted); + }; + + queueGesture(0); + await vi.waitFor(() => expect(runtime.commit).toHaveBeenCalledOnce()); + for (let index = 1; index < 34; index += 1) { + queueGesture(index); + } + + await vi.waitFor(() => + expect(runtime.cancel).toHaveBeenCalledWith(sessionIds[1]), + ); + runtime.rejectCommit(ioError()); + const results = await Promise.allSettled(commits); + + expect(results.every(({ status }) => status === 'rejected')).toBe(true); + expect(runtime.cancel).toHaveBeenCalledWith(sessionIds[0]); + expect(runtime.cancel).toHaveBeenCalledWith(sessionIds[1]); + expect([sessionIds[0], sessionIds[1]]).not.toContain( + editGestureController.activeGestureId(), + ); + }); +}); diff --git a/src/renderer/editor/runtime/editorStateCoordinator.test.ts b/src/renderer/editor/runtime/editorStateCoordinator.test.ts new file mode 100644 index 00000000..430d1b11 --- /dev/null +++ b/src/renderer/editor/runtime/editorStateCoordinator.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { EditorCommittedV1, EditorDocumentV1 } from '@src/types/editor'; + +const runtime = vi.hoisted(() => { + const document: EditorDocumentV1 = { + schemaVersion: 1, + keys: {}, + keyPositions: {}, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + layerGroups: {}, + }; + let listener: ((event: EditorCommittedV1) => void) | null = null; + const endSessions = vi.fn(); + const applyRemoteEnvelope = vi.fn(); + const cancel = vi.fn(async () => {}); + const subscribe = vi.fn(async () => 1); + const get = vi.fn(async () => ({ revision: 0, document })); + const commit = vi.fn(); + const onCommitted = vi.fn( + (nextListener: (event: EditorCommittedV1) => void) => { + listener = nextListener; + return Object.assign( + () => { + listener = null; + }, + { ready: Promise.resolve() }, + ); + }, + ); + + return { + commit, + applyRemoteEnvelope, + cancel, + endSessions, + get, + onCommitted, + subscribe, + emit: (event: EditorCommittedV1) => listener?.(event), + }; +}); + +vi.mock('@api/modules/editorApi', () => ({ + editorApi: { + get: runtime.get, + commit: runtime.commit, + onCommitted: runtime.onCommitted, + }, +})); + +vi.mock('@api/modules/previewApi', () => ({ + previewApi: { + cancel: runtime.cancel, + subscribe: runtime.subscribe, + }, +})); + +vi.mock('./previewOverlay', () => ({ + previewOverlay: { + applyRemoteEnvelope: runtime.applyRemoteEnvelope, + endSessions: runtime.endSessions, + }, +})); + +import { editorCoordinator } from './editorStateCoordinator'; + +const committedEvent = ( + mutationId: string, + gestures: Pick, +): EditorCommittedV1 => ({ + schemaVersion: 1, + revision: 0, + mutationId, + changedFields: [], + patch: { schemaVersion: 1 }, + ...gestures, +}); + +describe('editor state coordinator committed preview cleanup', () => { + afterEach(() => { + editorCoordinator.stop(); + }); + + it('recovers lazy subscription and maps committed gesture cleanup', async () => { + const initializationError = new Error('bootstrap get failed'); + runtime.get.mockRejectedValueOnce(initializationError); + + await expect(editorCoordinator.start()).rejects.toBe(initializationError); + expect(runtime.subscribe).not.toHaveBeenCalled(); + + await editorCoordinator.start(); + await editorCoordinator.start(); + expect(runtime.subscribe).toHaveBeenCalledOnce(); + + runtime.emit( + committedEvent('merged', { + gestureIds: [ + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000002', + ], + }), + ); + await vi.waitFor(() => expect(runtime.endSessions).toHaveBeenCalledOnce()); + expect(runtime.endSessions).toHaveBeenLastCalledWith([ + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000002', + ]); + + runtime.emit( + committedEvent('legacy', { + gestureId: '00000000-0000-4000-8000-000000000003', + }), + ); + await vi.waitFor(() => + expect(runtime.endSessions).toHaveBeenCalledTimes(2), + ); + expect(runtime.endSessions).toHaveBeenLastCalledWith([ + '00000000-0000-4000-8000-000000000003', + ]); + }); +}); diff --git a/src/renderer/editor/runtime/editorStateCoordinator.ts b/src/renderer/editor/runtime/editorStateCoordinator.ts index ef0af79a..1f3d2e9e 100644 --- a/src/renderer/editor/runtime/editorStateCoordinator.ts +++ b/src/renderer/editor/runtime/editorStateCoordinator.ts @@ -1,4 +1,5 @@ import { editorApi } from '@api/modules/editorApi'; +import { previewApi } from '@api/modules/previewApi'; import { unstable_batchedUpdates } from 'react-dom'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { @@ -12,13 +13,38 @@ import { invalidateSelectionForChangedIndexedElementArrays } from '@stores/grid/ import { stableStringify } from '@utils/core/stableStringify'; import { createEditorCoordinator } from './editorCoordinator'; -import { previewOverlay, registerPreviewRevisionProbe } from './previewOverlay'; +import { markGestureSessionsDiscarded } from './gestureSessionLifecycle'; +import { previewOverlay } from './previewOverlay'; import type { EditorDocumentV1 } from '@src/types/editor'; const hasChanged = (current: unknown, next: unknown) => stableStringify(current) !== stableStringify(next); +let previewSubscribed = false; +let previewSubscriptionInFlight: Promise | null = null; + +const ensurePreviewSubscription = (): Promise => { + if (previewSubscribed) return Promise.resolve(); + if (previewSubscriptionInFlight) return previewSubscriptionInFlight; + + // 채널은 unsubscribe API가 없어 창 단위 coordinator 수명에 맞춰 한 번만 등록 + previewSubscriptionInFlight = previewApi + .subscribe((envelope) => { + previewOverlay.applyRemoteEnvelope(envelope); + }) + .then(() => { + previewSubscribed = true; + }) + .catch((error) => { + console.error('프리뷰 채널 구독 실패', error); + }) + .finally(() => { + previewSubscriptionInFlight = null; + }); + return previewSubscriptionInFlight; +}; + export const captureEditorDocument = (): EditorDocumentV1 => { const keyState = useKeyStore.getState(); return { @@ -109,12 +135,16 @@ export const editorCoordinator = createEditorCoordinator({ }, // canonical 반영과 같은 처리 단위에서 해당 게스처의 프리뷰 오버레이 정리 onCommittedApplied: (event) => { - if (event.gestureId) previewOverlay.endSession(event.gestureId); + previewOverlay.endSessions( + event.gestureIds ?? (event.gestureId ? [event.gestureId] : []), + ); }, - // committed·resync 등 revision 전진 시 게이트가 풀린 지연 cancel 처리 - onCanonicalRevisionAdvanced: (revision) => { - previewOverlay.flushDeferredCancels(revision); + onGestureIdsDiscarded: (gestureIds) => { + markGestureSessionsDiscarded(gestureIds); + previewOverlay.endSessions(gestureIds); + gestureIds.forEach((gestureId) => { + previewApi.cancel(gestureId).catch(() => {}); + }); }, + onStartSucceeded: ensurePreviewSubscription, }); - -registerPreviewRevisionProbe(() => editorCoordinator.getState().revision); diff --git a/src/renderer/editor/runtime/gestureSessionLifecycle.ts b/src/renderer/editor/runtime/gestureSessionLifecycle.ts new file mode 100644 index 00000000..f2b25a0a --- /dev/null +++ b/src/renderer/editor/runtime/gestureSessionLifecycle.ts @@ -0,0 +1,31 @@ +export interface GestureSessionLifecycle { + readonly sessionId: string; + discarded: boolean; +} + +const sessions = new Map(); + +export const registerGestureSession = ( + sessionId: string, +): GestureSessionLifecycle => { + const lifecycle = { sessionId, discarded: false }; + sessions.set(sessionId, lifecycle); + return lifecycle; +}; + +export const markGestureSessionsDiscarded = ( + sessionIds: readonly string[], +): void => { + sessionIds.forEach((sessionId) => { + const lifecycle = sessions.get(sessionId); + if (lifecycle) lifecycle.discarded = true; + }); +}; + +export const releaseGestureSession = ( + lifecycle: GestureSessionLifecycle, +): void => { + if (sessions.get(lifecycle.sessionId) === lifecycle) { + sessions.delete(lifecycle.sessionId); + } +}; diff --git a/src/renderer/editor/runtime/previewOverlay.ts b/src/renderer/editor/runtime/previewOverlay.ts index 7474c663..2b267158 100644 --- a/src/renderer/editor/runtime/previewOverlay.ts +++ b/src/renderer/editor/runtime/previewOverlay.ts @@ -34,18 +34,6 @@ export const getPreviewOverlayVersion = () => overlayVersion; const TOMBSTONE_LIMIT = 64; const tombstones = new Set(); -// minRevision 게이트에 걸린 cancel, 해당 revision 반영 후 처리 -const deferredCancels = new Map(); - -// coordinator revision 조회 (editorStateCoordinator가 등록, 순환 import 회피) -let revisionProbe: (() => number | null) | null = null; - -export const registerPreviewRevisionProbe = ( - probe: (() => number | null) | null, -) => { - revisionProbe = probe; -}; - const addTombstone = (sessionId: string) => { tombstones.add(sessionId); if (tombstones.size > TOMBSTONE_LIMIT) { @@ -140,15 +128,6 @@ export const previewOverlay = { if (tombstones.has(envelope.sessionId)) return; if (envelope.kind === 'cancel') { - // 커밋 후속 cancel은 해당 revision이 canonical에 반영된 뒤에만 제거 - const minRevision = envelope.minRevision; - if (minRevision != null) { - const current = revisionProbe?.() ?? null; - if (current === null || current < minRevision) { - deferredCancels.set(envelope.sessionId, minRevision); - return; - } - } this.endSession(envelope.sessionId); return; } @@ -180,31 +159,26 @@ export const previewOverlay = { * 오버레이만 제거하며 canonical은 건드리지 않음 */ endSession(sessionId: string): void { - addTombstone(sessionId); - if (!sessions.delete(sessionId)) return; - refreshRenderedState(); + this.endSessions([sessionId]); }, - hasSession(sessionId: string): boolean { - return sessions.has(sessionId); + endSessions(sessionIds: readonly string[]): void { + let changed = false; + for (const sessionId of sessionIds) { + addTombstone(sessionId); + changed = sessions.delete(sessionId) || changed; + } + if (changed) refreshRenderedState(); }, - /** canonical 반영 후 게이트가 풀린 지연 cancel 처리 */ - flushDeferredCancels(currentRevision: number): void { - if (deferredCancels.size === 0) return; - for (const [sessionId, minRevision] of deferredCancels) { - if (currentRevision >= minRevision) { - deferredCancels.delete(sessionId); - this.endSession(sessionId); - } - } + hasSession(sessionId: string): boolean { + return sessions.has(sessionId); }, /** 테스트·리셋용 전체 정리 */ clearAll(): void { sessions.clear(); tombstones.clear(); - deferredCancels.clear(); refreshRenderedState(); }, }; diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts index e8c097ab..06c5916c 100644 --- a/src/renderer/hooks/app/useAppBootstrap.ts +++ b/src/renderer/hooks/app/useAppBootstrap.ts @@ -27,7 +27,6 @@ import { import { stableStringify } from '@utils/core/stableStringify'; import { useTranslation } from '@contexts/useTranslation'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; -import { previewApi } from '@api/modules/previewApi'; import { panelWindowApi } from '@api/modules/selectionSessionApi'; import { initSelectionSync, @@ -46,7 +45,6 @@ import { useHistoryStatusStore, syncHistoryStatus, } from '@stores/data/useHistoryStatusStore'; -import { previewOverlay } from '@src/renderer/editor/runtime/previewOverlay'; import { flushFocusedEditorForLifecycle } from '@src/renderer/editor/runtime/lifecycleEditorFlush'; import { acquireHistoryEditorFlushLock, @@ -523,15 +521,6 @@ export function useAppBootstrap() { console.error('편집 상태 초기화 실패', error); } - // 다른 창의 편집 프리뷰 수신 (재구독 시 브로커가 이전 채널 교체) - previewApi - .subscribe((envelope) => { - previewOverlay.applyRemoteEnvelope(envelope); - }) - .catch((error) => { - console.error('프리뷰 채널 구독 실패', error); - }); - // 백엔드 undo authority 상태 초기 조회 void syncHistoryStatus(); diff --git a/src/types/editor.ts b/src/types/editor.ts index 20d739f8..50086050 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -45,6 +45,8 @@ export interface EditorCommitRequest { changes: EditorPatchV1; // 프리뷰 게스처 커밋 연동용, 성공 시 committed 이벤트로 echo gestureId?: string; + // 한 요청으로 합쳐진 프리뷰 세션 전체 + gestureIds?: string[]; } export interface EditorCommitResult { @@ -66,11 +68,15 @@ export interface EditorCommittedV1 { patch: EditorPatchV1; // 커밋 요청의 gestureId echo, 수신 창의 프리뷰 오버레이 정리 신호 gestureId?: string | null; + // 합쳐진 커밋에 포함된 모든 프리뷰 세션 정리 신호 + gestureIds?: string[]; } export type EditorCommitErrorCode = | 'REVISION_CONFLICT' | 'VALIDATION_FAILED' + | 'TOO_MANY_GESTURE_IDS' + | 'INVALID_GESTURE_ID' | 'PAIRED_UPDATE_REQUIRED' | 'MUTATION_ID_REUSED' | 'IO_ERROR' @@ -95,6 +101,8 @@ export interface EditorCommitError { const EDITOR_ERROR_CODES = new Set([ 'REVISION_CONFLICT', 'VALIDATION_FAILED', + 'TOO_MANY_GESTURE_IDS', + 'INVALID_GESTURE_ID', 'PAIRED_UPDATE_REQUIRED', 'MUTATION_ID_REUSED', 'IO_ERROR', @@ -498,6 +506,14 @@ export function assertEditorCommittedEvent(value: EditorCommittedV1): void { if (typeof value.mutationId !== 'string' || value.mutationId.length === 0) { throw new EditorProtocolError('editor:committed mutationId is invalid'); } + if ( + value.gestureIds !== undefined && + (!Array.isArray(value.gestureIds) || + value.gestureIds.some((gestureId) => typeof gestureId !== 'string') || + new Set(value.gestureIds).size !== value.gestureIds.length) + ) { + throw new EditorProtocolError('editor:committed gestureIds is invalid'); + } assertEditorFields(value.changedFields, 'editor:committed changedFields'); assertEditorPatch(value.patch, 'editor:committed patch'); const patchFields = EDITOR_FIELDS.filter( diff --git a/src/types/preview.ts b/src/types/preview.ts index a5c40b63..5bf6d308 100644 --- a/src/types/preview.ts +++ b/src/types/preview.ts @@ -21,8 +21,6 @@ export interface PreviewEnvelope { mode: string; targets: number[]; patch: Record; - // cancel 전용: 이 revision 반영 후에만 세션 제거 (순서 게이트) - minRevision?: number | null; } export interface PreviewPublishRequest { From 7f2a580ff03c15c061a38f9741754970d5cd6750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 21 Jul 2026 19:03:06 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=EB=B6=84=EB=A6=AC=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20bounds=20=EC=A0=80=EC=9E=A5=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=A0=95=EB=B9=84=EC=99=80=20macOS=20even?= =?UTF-8?q?tNumber=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/layout/panel.rs | 25 +- src-tauri/src/state/app_state.rs | 728 ++++++++++++++++++------- 2 files changed, 543 insertions(+), 210 deletions(-) diff --git a/src-tauri/src/commands/layout/panel.rs b/src-tauri/src/commands/layout/panel.rs index 18d24987..8186c212 100644 --- a/src-tauri/src/commands/layout/panel.rs +++ b/src-tauri/src/commands/layout/panel.rs @@ -120,7 +120,13 @@ fn start_panel_window_dragging( } else { let modifier_flags = NSEvent::modifierFlags(current_event); let timestamp = NSEvent::timestamp(current_event); - let event_number = NSEvent::eventNumber(current_event); + // eventNumber는 마우스 계열 이벤트에만 유효, 키 이벤트 등에 조회하면 NSInternalInconsistencyException + let event_type: NSUInteger = msg_send![current_event, type]; + let event_number = if is_mouse_event_type(event_type) { + NSEvent::eventNumber(current_event) + } else { + 0 + }; (modifier_flags, timestamp, event_number) }; @@ -176,9 +182,24 @@ fn is_panel_mouse_down_event( event_type == 1 && event_window_number == target_window_number } +// NSLeftMouseDown(1)~NSMouseExited(9), NSOtherMouse*(25~27) +fn is_mouse_event_type(event_type: u64) -> bool { + matches!(event_type, 1..=9 | 25..=27) +} + #[cfg(test)] mod tests { - use super::{is_panel_mouse_down_event, panel_drag_local_coordinates}; + use super::{is_mouse_event_type, is_panel_mouse_down_event, panel_drag_local_coordinates}; + + #[test] + fn panel_drag_synthetic_event_borrows_event_number_from_mouse_events_only() { + assert!(is_mouse_event_type(1)); + assert!(is_mouse_event_type(9)); + assert!(is_mouse_event_type(26)); + assert!(!is_mouse_event_type(10)); // NSKeyDown + assert!(!is_mouse_event_type(11)); // NSKeyUp + assert!(!is_mouse_event_type(12)); // NSFlagsChanged + } #[test] fn panel_drag_reuses_only_the_target_windows_mouse_down_event() { diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index a1f089cd..db10c250 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -93,7 +93,7 @@ struct ShutdownWatchdogState { stage: &'static str, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] struct PanelBoundsSample { position: PhysicalPosition, position_scale_factor: f64, @@ -104,6 +104,7 @@ struct PanelBoundsSample { #[derive(Debug, Clone, Copy)] enum PanelBoundsChange { + Snapshot(PanelBoundsSample), Moved(PhysicalPosition), Resized(PhysicalSize), ScaleFactorChanged { @@ -113,14 +114,31 @@ enum PanelBoundsChange { }, } -#[derive(Debug)] -struct PanelBoundsSettleState { +#[derive(Default)] +struct PanelBoundsPersistenceState { latest: Option, + window: Option, + applied_max_height: Option, + session: u64, generation: u64, worker_running: bool, + dirty: bool, active: bool, } +#[derive(Debug, Clone, Copy, PartialEq)] +struct PanelBoundsPersistWork { + session: u64, + generation: u64, + sample: PanelBoundsSample, +} + +struct PanelBoundsPersistenceController { + store: Arc, + state: Mutex, + persist_lock: Mutex<()>, +} + #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) enum FrontendLifecycleAction { @@ -763,7 +781,7 @@ pub struct AppState { selection_session: Mutex, plugin_authority: PluginRuntimeAuthority, plugin_rpc_router: PluginRpcRouter, - panel_bounds_generation: Arc, + panel_bounds_persistence: Arc, panel_visible: AtomicBool, panel_creation_lock: Mutex<()>, panel_close_request: Mutex, @@ -819,6 +837,8 @@ impl AppState { let key_sound = Arc::new(KeySoundEngine::with_output_backend(initial_backend)); let obs_bridge = Arc::new(ObsBridgeService::new(env!("CARGO_PKG_VERSION"))); let authorized_css_paths = collect_authorized_css_paths(&snapshot); + let panel_bounds_persistence = + Arc::new(PanelBoundsPersistenceController::new(Arc::clone(&store))); let selection_session = SelectionSessionSnapshot { mode: snapshot.selected_key_type.clone(), ..SelectionSessionSnapshot::default() @@ -836,7 +856,7 @@ impl AppState { selection_session: Mutex::new(selection_session), plugin_authority: PluginRuntimeAuthority::default(), plugin_rpc_router: PluginRpcRouter::default(), - panel_bounds_generation: Arc::new(AtomicU64::new(0)), + panel_bounds_persistence, panel_visible: AtomicBool::new(false), panel_creation_lock: Mutex::new(()), panel_close_request: Mutex::new(PanelCloseRequestState::Idle), @@ -2559,16 +2579,9 @@ impl AppState { *self.panel_destroy_reason.lock() = Some(reason); let mut bounds_error = None; if let Some(window) = app.get_webview_window(PANEL_LABEL) { - if let Err(error) = - defer_panel_bounds_from_window(&window, &self.store, &self.panel_bounds_generation) - { + if let Err(error) = self.panel_bounds_persistence.flush_now(&window) { bounds_error = Some(error); } - if let Err(error) = - flush_deferred_panel_bounds(&self.store, &self.panel_bounds_generation) - { - bounds_error.get_or_insert(error); - } if let Err(error) = window.destroy() { self.clear_panel_destroy_reason(reason); return Err(error.into()); @@ -2617,8 +2630,7 @@ impl AppState { let Some(window) = app.get_webview_window(PANEL_LABEL) else { return Ok(()); }; - defer_panel_bounds_from_window(&window, &self.store, &self.panel_bounds_generation)?; - flush_deferred_panel_bounds(&self.store, &self.panel_bounds_generation) + self.panel_bounds_persistence.flush_now(&window) } pub fn handle_panel_window_destroyed(&self, app: &AppHandle) { @@ -2682,25 +2694,22 @@ impl AppState { app: &AppHandle, initial_max_height: f64, ) { - let store = self.store.clone(); - let bounds_generation = self.panel_bounds_generation.clone(); + let bounds_session = self + .panel_bounds_persistence + .attach(window, initial_max_height); + let bounds_persistence = Arc::clone(&self.panel_bounds_persistence); let panel_window = window.clone(); let app_handle = app.clone(); - let panel_max_height = Arc::new(Mutex::new(Some(initial_max_height))); - let panel_bounds_settle = Arc::new(Mutex::new(PanelBoundsSettleState { - latest: panel_bounds_sample_from_window(window).ok(), - generation: 0, - worker_running: false, - active: true, - })); window.on_window_event(move |event| match event { WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); - if let Err(err) = - defer_panel_bounds_from_window(&panel_window, &store, &bounds_generation) - { - log::warn!("failed to capture panel bounds on close request: {err}"); + match panel_bounds_sample_from_window(&panel_window) { + Ok(sample) => bounds_persistence + .record_event(bounds_session, PanelBoundsChange::Snapshot(sample)), + Err(err) => { + log::warn!("failed to capture panel bounds on close request: {err}") + } } let Some(state) = app_handle.try_state::() else { return; @@ -2735,36 +2744,19 @@ impl AppState { }); } WindowEvent::Moved(position) => { - schedule_panel_bounds_settle( - &panel_window, - &store, - &bounds_generation, - &panel_max_height, - &panel_bounds_settle, - PanelBoundsChange::Moved(*position), - ); + bounds_persistence + .record_event(bounds_session, PanelBoundsChange::Moved(*position)); } WindowEvent::Resized(size) => { - schedule_panel_bounds_settle( - &panel_window, - &store, - &bounds_generation, - &panel_max_height, - &panel_bounds_settle, - PanelBoundsChange::Resized(*size), - ); + bounds_persistence.record_event(bounds_session, PanelBoundsChange::Resized(*size)); } WindowEvent::ScaleFactorChanged { scale_factor, new_inner_size, .. } => { - schedule_panel_bounds_settle( - &panel_window, - &store, - &bounds_generation, - &panel_max_height, - &panel_bounds_settle, + bounds_persistence.record_event( + bounds_session, PanelBoundsChange::ScaleFactorChanged { position: panel_window.outer_position().ok(), size: *new_inner_size, @@ -2773,7 +2765,7 @@ impl AppState { ); } WindowEvent::Destroyed => { - panel_bounds_settle.lock().active = false; + bounds_persistence.deactivate(bounds_session); } _ => {} }); @@ -4283,6 +4275,7 @@ fn panel_bounds_from_sample(sample: PanelBoundsSample) -> PanelBounds { fn apply_panel_bounds_change(sample: &mut PanelBoundsSample, change: PanelBoundsChange) { match change { + PanelBoundsChange::Snapshot(snapshot) => *sample = snapshot, PanelBoundsChange::Moved(position) => { sample.position = position; sample.position_scale_factor = sample.current_scale_factor; @@ -4308,185 +4301,266 @@ fn apply_panel_bounds_change(sample: &mut PanelBoundsSample, change: PanelBounds } } -fn schedule_panel_bounds_settle( - window: &WebviewWindow, - store: &Arc, - generation: &Arc, - applied_max_height: &Arc>>, - settle_state: &Arc>, - change: PanelBoundsChange, -) { - let mut state = settle_state.lock(); - if !state.active { - return; +impl PanelBoundsPersistenceController { + fn new(store: Arc) -> Self { + Self { + store, + state: Mutex::new(PanelBoundsPersistenceState::default()), + persist_lock: Mutex::new(()), + } } - if state.latest.is_none() { - state.latest = panel_bounds_sample_from_window(window).ok(); + + fn attach(&self, window: &WebviewWindow, max_height: f64) -> u64 { + let latest = panel_bounds_sample_from_window(window).ok(); + let mut state = self.state.lock(); + state.session = state.session.wrapping_add(1); + state.latest = latest; + state.window = Some(window.clone()); + state.applied_max_height = Some(max_height); + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.active = true; + state.session + } + + fn record_event(self: &Arc, session: u64, change: PanelBoundsChange) { + let should_spawn = { + let mut state = self.state.lock(); + if !state.active || state.session != session { + return; + } + if state.latest.is_none() { + state.latest = state + .window + .as_ref() + .and_then(|window| panel_bounds_sample_from_window(window).ok()); + } + Self::record_change(&mut state, session, change) + }; + if should_spawn { + self.spawn_worker(); + } } - if let Some(latest) = state.latest.as_mut() { + + fn record_change( + state: &mut PanelBoundsPersistenceState, + session: u64, + change: PanelBoundsChange, + ) -> bool { + if !state.active || state.session != session { + return false; + } + if let PanelBoundsChange::Snapshot(snapshot) = change { + state.latest = Some(snapshot); + return Self::mark_dirty(state); + } + let Some(latest) = state.latest.as_mut() else { + return false; + }; apply_panel_bounds_change(latest, change); + Self::mark_dirty(state) } - let scheduled_generation = generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1); - state.generation = scheduled_generation; - if state.worker_running { - return; + + fn mark_dirty(state: &mut PanelBoundsPersistenceState) -> bool { + state.generation = state.generation.wrapping_add(1); + state.dirty = true; + let should_spawn = !state.worker_running; + state.worker_running = true; + should_spawn } - state.worker_running = true; - drop(state); - let window = window.clone(); - let store = Arc::clone(store); - let generation = Arc::clone(generation); - let applied_max_height = Arc::clone(applied_max_height); - let settle_state = Arc::clone(settle_state); - tauri::async_runtime::spawn(async move { - loop { - tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; - let (observed_generation, sample) = { - let mut state = settle_state.lock(); - if !state.active { - state.worker_running = false; - return; - } - if generation.load(Ordering::SeqCst) != state.generation { - state.worker_running = false; - return; - } - let Some(sample) = state.latest else { - state.worker_running = false; - return; - }; - (state.generation, sample) - }; + fn take_dirty_work(state: &mut PanelBoundsPersistenceState) -> Option { + let sample = state.latest.filter(|_| state.active && state.dirty)?; + state.dirty = false; + Some(PanelBoundsPersistWork { + session: state.session, + generation: state.generation, + sample, + }) + } - let is_current = || { - let state = settle_state.lock(); - state.active - && state.generation == observed_generation - && generation.load(Ordering::SeqCst) == observed_generation - }; - if !is_current() { - continue; - } + fn work_is_current(state: &PanelBoundsPersistenceState, work: &PanelBoundsPersistWork) -> bool { + state.active && state.session == work.session && state.generation == work.generation + } - let bounds = panel_bounds_from_sample(sample); - if !is_current() { - continue; - } - let persisted = Arc::new(AtomicBool::new(false)); - let persist_generation = Arc::clone(&generation); - let persist_state = Arc::clone(&settle_state); - let persisted_result = Arc::clone(&persisted); - if let Err(err) = store.update_deferred(move |state| { - let settle = persist_state.lock(); - if settle.active - && settle.generation == observed_generation - && persist_generation.load(Ordering::SeqCst) == observed_generation - { - state.panel_bounds = Some(bounds); - persisted_result.store(true, Ordering::SeqCst); + fn restore_failed_work( + state: &mut PanelBoundsPersistenceState, + work: &PanelBoundsPersistWork, + ) -> bool { + if !Self::work_is_current(state, work) { + return false; + } + state.dirty = true; + true + } + + fn persist_sample(&self, sample: PanelBoundsSample) -> Result<()> { + let bounds = panel_bounds_from_sample(sample); + self.store + .update_deferred(move |data| { + data.panel_bounds = Some(bounds); + }) + .context("failed to capture settled panel bounds")?; + self.store + .flush() + .context("failed to flush settled panel bounds") + } + + fn persist_worker_work(&self, work: PanelBoundsPersistWork) -> Result { + let _persist_guard = self.persist_lock.lock(); + if !Self::work_is_current(&self.state.lock(), &work) { + return Ok(false); + } + self.persist_sample(work.sample)?; + Ok(true) + } + + fn spawn_worker(self: &Arc) { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; + let (work, window) = { + let mut state = controller.state.lock(); + let Some(work) = Self::take_dirty_work(&mut state) else { + state.worker_running = false; + return; + }; + (work, state.window.clone()) + }; + + let persist_result = controller.persist_worker_work(work); + if let Err(err) = &persist_result { + log::warn!("failed to persist settled panel bounds: {err:#}"); } - }) { - log::warn!("failed to capture settled panel bounds: {err}"); - } else if persisted.load(Ordering::SeqCst) && is_current() { - if let Err(err) = store.flush() { - log::warn!("failed to flush settled panel bounds: {err}"); + + if let Some(window) = window { + let constraint_controller = Arc::clone(&controller); + let constraint_window = window.clone(); + if let Err(err) = window.app_handle().run_on_main_thread(move || { + if let Err(err) = constraint_controller.apply_monitor_constraints( + &constraint_window, + work.session, + work.generation, + ) { + log::warn!("failed to update settled panel monitor constraints: {err}"); + } + }) { + log::warn!("failed to schedule settled panel constraints: {err}"); + } } - } - let constraint_window = window.clone(); - let constraint_max_height = Arc::clone(&applied_max_height); - let constraint_generation = Arc::clone(&generation); - let constraint_state = Arc::clone(&settle_state); - if let Err(err) = window.app_handle().run_on_main_thread(move || { - let is_current = { - let state = constraint_state.lock(); - state.active - && state.generation == observed_generation - && constraint_generation.load(Ordering::SeqCst) == observed_generation - }; - if !is_current { + let mut state = controller.state.lock(); + if persist_result.is_err() && Self::restore_failed_work(&mut state, &work) { + state.worker_running = false; return; } - if let Err(err) = - apply_panel_monitor_constraints(&constraint_window, &constraint_max_height) - { - log::warn!("failed to update settled panel monitor constraints: {err}"); + if !state.active || !state.dirty { + state.worker_running = false; + return; } - }) { - log::warn!("failed to schedule settled panel constraints: {err}"); } + }); + } - let mut state = settle_state.lock(); - if state.generation == observed_generation - && generation.load(Ordering::SeqCst) == observed_generation - { - state.worker_running = false; - return; + fn apply_monitor_constraints( + &self, + window: &WebviewWindow, + session: u64, + generation: u64, + ) -> Result<()> { + let Some(max_height) = window + .current_monitor()? + .and_then(MonitorSpec::from_monitor) + .map(|monitor| panel_max_height(Some(monitor.logical_height))) + else { + return Ok(()); + }; + let max_height = { + let state = self.state.lock(); + if !state.active || state.session != session || state.generation != generation { + return Ok(()); } + changed_panel_max_height(state.applied_max_height, max_height) + }; + let Some(max_height) = max_height else { + return Ok(()); + }; + window.set_max_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( + PANEL_WIDTH, + max_height, + ))))?; + let mut state = self.state.lock(); + if state.active && state.session == session && state.generation == generation { + state.applied_max_height = Some(max_height); } - }); -} + Ok(()) + } -fn apply_panel_monitor_constraints( - window: &WebviewWindow, - applied_max_height: &Mutex>, -) -> Result<()> { - let Some(max_height) = window - .current_monitor()? - .and_then(MonitorSpec::from_monitor) - .map(|monitor| panel_max_height(Some(monitor.logical_height))) - else { - return Ok(()); - }; - let mut applied = applied_max_height.lock(); - let Some(max_height) = changed_panel_max_height(*applied, max_height) else { - return Ok(()); - }; - window.set_max_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( - PANEL_WIDTH, - max_height, - ))))?; - *applied = Some(max_height); - Ok(()) -} + fn flush_now(&self, window: &WebviewWindow) -> Result<()> { + let _persist_guard = self.persist_lock.lock(); + let generation_before_sample = self.state.lock().generation; + let sampled = panel_bounds_sample_from_window(window); + Self::flush_samples(&self.state, generation_before_sample, sampled, |sample| { + self.persist_sample(sample) + }) + } -fn defer_panel_bounds_from_window( - window: &WebviewWindow, - store: &Arc, - generation: &Arc, -) -> Result<()> { - let scale_factor = window.scale_factor().unwrap_or(1.0); - let position = window.outer_position()?.to_logical::(scale_factor); - let size = window.inner_size()?.to_logical::(scale_factor); - let bounds = PanelBounds { - x: position.x, - y: position.y, - height: size.height.max(PANEL_MIN_HEIGHT), - }; - let scheduled_generation = generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1); - store.update_deferred(move |state| { - state.panel_bounds = Some(bounds); - })?; + fn flush_samples( + state_mutex: &Mutex, + generation_before_sample: u64, + sampled: Result, + mut persist: impl FnMut(PanelBoundsSample) -> Result<()>, + ) -> Result<()> { + let mut work = { + let mut state = state_mutex.lock(); + let sampled = match sampled { + Ok(sample) => sample, + Err(error) => state.latest.ok_or(error)?, + }; + let sample = if state.generation != generation_before_sample { + state.latest.unwrap_or(sampled) + } else { + sampled + }; + state.latest = Some(sample); + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + PanelBoundsPersistWork { + session: state.session, + generation: state.generation, + sample, + } + }; - let store = Arc::clone(store); - let generation = Arc::clone(generation); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; - if generation.load(Ordering::SeqCst) != scheduled_generation { - return; - } - if let Err(err) = store.flush() { - log::warn!("failed to flush debounced panel bounds: {err}"); + loop { + if let Err(error) = persist(work.sample) { + Self::restore_failed_work(&mut state_mutex.lock(), &work); + return Err(error); + } + let Some(next) = Self::take_dirty_work(&mut state_mutex.lock()) else { + return Ok(()); + }; + work = next; } - }); + } - Ok(()) -} + fn deactivate(&self, session: u64) { + let mut state = self.state.lock(); + Self::deactivate_state(&mut state, session); + } -fn flush_deferred_panel_bounds(store: &Arc, generation: &Arc) -> Result<()> { - generation.fetch_add(1, Ordering::SeqCst); - store.flush() + fn deactivate_state(state: &mut PanelBoundsPersistenceState, session: u64) -> bool { + if state.session != session { + return false; + } + state.active = false; + state.window = None; + state.applied_max_height = None; + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + true + } } fn defer_overlay_bounds_from_window( @@ -4607,7 +4681,8 @@ mod tests { take_targeted_panel_view_state, validate_selection_session, EditorFlushAcknowledge, EditorFlushCompletion, EditorFlushHandshake, EditorFlushRequest, FrontendFlushAction, FrontendHistoryFlushPhase, FrontendHistoryFlushReady, FrontendLifecycleAction, - LifecycleHandshakeInstall, MonitorData, Mutex, PanelBoundsChange, PanelBoundsSample, + LifecycleHandshakeInstall, MonitorData, Mutex, PanelBoundsChange, + PanelBoundsPersistenceController, PanelBoundsPersistenceState, PanelBoundsSample, PanelCloseRequestState, PanelCloseRequestedPayload, PanelLayerTab, PanelPropertyTab, PanelViewMode, PanelViewState, PanelViewTarget, PanelVisibilityEventEmitter, PanelVisibilityPayload, PanelVisibilityReason, PhysicalPosition, PhysicalSize, @@ -4712,7 +4787,7 @@ mod tests { } #[test] - fn settled_panel_bounds_preserve_each_geometry_scale_domain() { + fn panel_bounds_controller_preserves_scale_domains_and_coalesces_events() { let mut sample = PanelBoundsSample { position: PhysicalPosition::new(600, 300), position_scale_factor: 2.0, @@ -4733,6 +4808,243 @@ mod tests { assert_eq!(bounds.x, 300.0); assert_eq!(bounds.y, 150.0); assert_eq!(bounds.height, 1_000.0); + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + session: 1, + active: true, + ..PanelBoundsPersistenceState::default() + }; + assert!(PanelBoundsPersistenceController::record_change( + &mut state, + 1, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + assert!(!PanelBoundsPersistenceController::record_change( + &mut state, + 1, + PanelBoundsChange::Resized(PhysicalSize::new(480, 1_200)), + )); + assert_eq!( + (state.generation, state.worker_running, state.dirty), + (2, true, true) + ); + let mut empty_state = PanelBoundsPersistenceState { + session: 1, + active: true, + ..PanelBoundsPersistenceState::default() + }; + assert!(PanelBoundsPersistenceController::record_change( + &mut empty_state, + 1, + PanelBoundsChange::Snapshot(sample), + )); + assert_eq!(empty_state.latest, Some(sample)); + } + + #[test] + fn panel_bounds_deactivate_ignores_a_stale_session_token() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + applied_max_height: Some(900.0), + session: 2, + generation: 7, + dirty: true, + active: true, + ..PanelBoundsPersistenceState::default() + }; + + assert!(!PanelBoundsPersistenceController::deactivate_state( + &mut state, 1, + )); + assert!(state.active); + assert_eq!(state.session, 2); + assert_eq!(state.generation, 7); + assert!(state.dirty); + assert_eq!(state.latest, Some(sample)); + assert_eq!(state.applied_max_height, Some(900.0)); + } + + #[test] + fn panel_bounds_record_event_ignores_a_stale_session_token() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + session: 2, + generation: 7, + active: true, + ..PanelBoundsPersistenceState::default() + }; + + assert!(!PanelBoundsPersistenceController::record_change( + &mut state, + 1, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + assert_eq!(state.latest, Some(sample)); + assert_eq!(state.generation, 7); + assert!(!state.dirty); + assert!(!state.worker_running); + } + + #[test] + fn panel_bounds_flush_keeps_event_after_sample_that_old_code_overwrote() { + let initial = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(initial), + session: 4, + active: true, + ..PanelBoundsPersistenceState::default() + }); + let generation_before_sample = state.lock().generation; + let sampled = state.lock().latest.unwrap(); + + assert!(PanelBoundsPersistenceController::record_change( + &mut state.lock(), + 4, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + let expected = state.lock().latest.unwrap(); + let mut persisted = Vec::new(); + + PanelBoundsPersistenceController::flush_samples( + &state, + generation_before_sample, + Ok(sampled), + |sample| { + persisted.push(sample); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(persisted, vec![expected]); + let state = state.lock(); + assert_eq!(state.latest, Some(expected)); + assert!(!state.dirty); + } + + #[test] + fn panel_bounds_flush_and_event_interleave_without_loss_or_duplicate_sample() { + let initial = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(initial), + session: 4, + active: true, + ..PanelBoundsPersistenceState::default() + }); + let mut persisted = Vec::new(); + + PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(initial), |sample| { + persisted.push(sample); + if persisted.len() == 1 { + assert!(PanelBoundsPersistenceController::record_change( + &mut state.lock(), + 4, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + } + Ok(()) + }) + .unwrap(); + + let mut expected_latest = initial; + apply_panel_bounds_change( + &mut expected_latest, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + ); + assert_eq!(persisted, vec![initial, expected_latest]); + let state = state.lock(); + assert_eq!(state.latest, Some(expected_latest)); + assert!(!state.dirty); + } + + #[test] + fn panel_bounds_flush_uses_latest_when_window_read_fails() { + let latest = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(latest), + session: 5, + active: true, + ..PanelBoundsPersistenceState::default() + }); + let mut persisted = Vec::new(); + + PanelBoundsPersistenceController::flush_samples( + &state, + 0, + Err(anyhow::anyhow!("window bounds unavailable")), + |sample| { + persisted.push(sample); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(persisted, vec![latest]); + + let empty = Mutex::new(PanelBoundsPersistenceState::default()); + assert!(PanelBoundsPersistenceController::flush_samples( + &empty, + 0, + Err(anyhow::anyhow!("window bounds unavailable")), + |_| panic!("missing bounds must not be persisted"), + ) + .is_err()); + } + + #[test] + fn panel_bounds_flush_failure_restores_dirty_state() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(sample), + session: 6, + active: true, + ..PanelBoundsPersistenceState::default() + }); + + assert!( + PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(sample), |_| Err( + anyhow::anyhow!("disk unavailable") + ),) + .is_err() + ); + assert!(state.lock().dirty); } #[test] From 2ab8dabf36f2e1e103a9f6bc03ea36da54e1003b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 21 Jul 2026 19:03:06 +0900 Subject: [PATCH 4/7] =?UTF-8?q?chore:=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A5=BC=20DmNote-App=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80=ED=95=98=EA=B3=A0=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=A7=81=ED=81=AC=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/guide/installation/page.mdx | 2 +- docs/content/ko/guide/installation/page.mdx | 2 +- src-tauri/src/commands/app/update.rs | 2 +- src/renderer/components/main/Settings.tsx | 16 ++++++++-------- src/renderer/components/main/Tool/ToolBar.tsx | 4 ++-- src/renderer/stores/useUpdateStore.ts | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/content/en/guide/installation/page.mdx b/docs/content/en/guide/installation/page.mdx index 72c0a7de..49651d73 100644 --- a/docs/content/en/guide/installation/page.mdx +++ b/docs/content/en/guide/installation/page.mdx @@ -9,7 +9,7 @@ description: How to download, install, and run DM Note DM Note can be downloaded from GitHub Releases. -1. Download the latest version from the [GitHub Releases page](https://github.com/lee-sihun/DmNote/releases). +1. Download the latest version from the [GitHub Releases page](https://github.com/DmNote-App/DmNote/releases). 2. Extract the downloaded ZIP file to your desired location. 3. Run the `DM Note.exe` file. diff --git a/docs/content/ko/guide/installation/page.mdx b/docs/content/ko/guide/installation/page.mdx index e4d77302..658e5bdf 100644 --- a/docs/content/ko/guide/installation/page.mdx +++ b/docs/content/ko/guide/installation/page.mdx @@ -9,7 +9,7 @@ description: DM Note 다운로드, 설치 및 실행 방법 DM Note는 GitHub Releases에서 다운로드할 수 있습니다. -1. [GitHub Releases 페이지](https://github.com/lee-sihun/DmNote/releases)에서 최신 버전을 다운로드합니다. +1. [GitHub Releases 페이지](https://github.com/DmNote-App/DmNote/releases)에서 최신 버전을 다운로드합니다. 2. 다운로드한 ZIP 파일의 압축을 원하는 위치에 해제합니다. 3. `DM Note.exe` 파일을 실행합니다. diff --git a/src-tauri/src/commands/app/update.rs b/src-tauri/src/commands/app/update.rs index ccf9525e..b92798a6 100644 --- a/src-tauri/src/commands/app/update.rs +++ b/src-tauri/src/commands/app/update.rs @@ -56,7 +56,7 @@ fn app_auto_update_windows( ) -> CmdResult { use std::time::Duration; - const REPO_OWNER: &str = "lee-sihun"; + const REPO_OWNER: &str = "DmNote-App"; const REPO_NAME: &str = "DmNote"; const ASSET_NAME: &str = "DM.NOTE.exe"; const SIGNATURE_ASSET_NAME: &str = "DM.NOTE.exe.sig"; diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 9e385355..962259e2 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -50,21 +50,21 @@ import { DEFAULT_OBS_PORT } from '@src/types/obs'; // 설정 미리보기 영상 const PREVIEW_SOURCES: Record = { overlayLock: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/overlay-lock.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/overlay-lock.webm', alwaysOnTop: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/alwaysontop.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/alwaysontop.webm', noteEffect: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/noteeffect.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/noteeffect.webm', keyCounter: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/counter.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/counter.webm', customCSS: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/css.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/css.webm', customJS: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/plugin.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/plugin.webm', resizeAnchor: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/resize.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/resize.webm', obsMode: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/obs.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/obs.webm', }; // ASIO 버퍼 크기 선택지(프레임). 게임 설정값과 맞춰야 ASIO 공존 가능. diff --git a/src/renderer/components/main/Tool/ToolBar.tsx b/src/renderer/components/main/Tool/ToolBar.tsx index fd02ab24..1be0370b 100644 --- a/src/renderer/components/main/Tool/ToolBar.tsx +++ b/src/renderer/components/main/Tool/ToolBar.tsx @@ -56,7 +56,7 @@ const ToolBar = ({