From 57676371dcc0599ecd4dba91ad20693f04e408ea Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:05:26 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EC=98=B5=EC=85=98=20=EA=B8=80=EC=9D=98?= =?UTF-8?q?=20=EC=A4=84=EB=B0=94=EA=BF=88=20=EC=97=86=EB=8A=94=20=EB=B9=88?= =?UTF-8?q?=EC=B9=B8=20=C2=B7=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EA=B8=80=EC=9E=90=20(=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=A7=80=EC=8B=9C=2010/20):=20=EC=98=B5=EC=85=98=EC=9D=84?= =?UTF-8?q?=20=EB=82=98=EB=88=8C=20=EB=95=8C=20(splitOptions)=20=EC=99=80?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EC=B9=B8=EC=97=90=20=EB=B6=99=EC=97=AC?= =?UTF-8?q?=20=EB=84=A3=EC=9D=84=20=EB=95=8C=20(OptionTextFormatter)=20?= =?UTF-8?q?=EB=B3=B4=ED=86=B5=20=EB=B9=88=EC=B9=B8=EC=9C=BC=EB=A1=9C=20/?= =?UTF-8?q?=20=EC=A7=80=EC=9B=80=20-=20=EB=AC=B6=EC=9D=80=20=EA=B8=80=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EA=B3=B3=EC=9D=98=20=EA=B8=80?= =?UTF-8?q?=EC=9D=84=20=EB=B6=99=EC=97=AC=20=EB=84=A3=EC=96=B4=EB=8F=84=20?= =?UTF-8?q?rsync=20=EA=B0=80=20=EA=B0=99=EC=9D=80=20=EC=9D=B8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BD=EC=9D=8C=20=C2=B7=20=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=EC=9D=98=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=EB=8A=94=20\u=20=ED=91=9C=EA=B8=B0=EB=A1=9C?= =?UTF-8?q?=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- lib/core/sync_tools.dart | 10 ++++++++-- lib/ui/copy_sync_settings.dart | 2 ++ lib/ui/monitor_page.dart | 3 ++- lib/ui/sync_preview_view.dart | 21 ++++++++++++++++++++- test/unbreakable_option_test.dart | 31 ++++++++++++++++++++++++++++++- 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index 0489171..22dac21 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -21,13 +21,19 @@ enum CopyMethod { const defaultRsyncOptions = '-avPog'; const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; -/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로) +/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) · +/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움. +/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다 +String cleanOptionText(String s) => + s.replaceAll(RegExp('[\u00A0\u2007\u202F]'), ' ').replaceAll(RegExp('[\u200B\u200C\u200D\u2060\uFEFF]'), ''); + +/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText] List splitOptions(String s) { final out = []; final cur = StringBuffer(); String? quote; var has = false; - for (final ch in s.split('')) { + for (final ch in cleanOptionText(s).split('')) { if (quote != null) { if (ch == quote) { quote = null; diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 2533172..56d4a6d 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -19,6 +19,7 @@ import 'rsync_setup.dart'; import 'schedule_editor.dart'; import 'theme.dart'; import 'setting_tile.dart'; +import 'sync_preview_view.dart' show OptionTextFormatter; /// 환경 설정의 복사 · 동기화 부분. /// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한 @@ -84,6 +85,7 @@ class _CopySyncSettingsState extends State { width: 380, child: TextFormField( controller: ctl, + inputFormatters: const [OptionTextFormatter()], scrollPadding: const EdgeInsets.all(80), style: const TextStyle(fontFamily: 'monospace', fontSize: 13), decoration: InputDecoration( diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 5e00aa8..2e6431f 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -21,7 +21,7 @@ import 'path_label.dart'; import 'rsync_setup.dart'; import 'schedule_editor.dart'; import '../core/sync_preview.dart' show optionsRemoveSource; -import 'sync_preview_view.dart' show FilterLimitNote, confirmDeletingRun, effectiveRunText, unbreakableOption; +import 'sync_preview_view.dart' show FilterLimitNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, unbreakableOption; import 'theme.dart'; /// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다. @@ -647,6 +647,7 @@ class _CopyCardState extends State<_CopyCard> { width: 320, child: TextField( controller: _options, + inputFormatters: const [OptionTextFormatter()], enabled: _method != 'builtin', style: const TextStyle(fontFamily: 'monospace', fontSize: 13), decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint), diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index 3235a1c..015fa1d 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../app/settings.dart' show CopyTask; import '../core/file_ops.dart' show formatSize, isPartialFile; @@ -309,7 +310,25 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM /// 지울 목록이 실제보다 적게 보일 수 있다 - 빨갛게 알리고, 지우기 (--delete) 와 함께면 체크해야 [실행] 이 켜진다 (72 · 104 와 같게) /// 옵션 글 (예: --filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리지 않게 줄바꿈 없이 묶는다 (관리자 지시 10/20, 28 지적): /// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음) -String unbreakableOption(String s) => s.replaceAll(' ', ' ').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}⁠'); +String unbreakableOption(String s) => s.replaceAll(' ', '\u00A0').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}\u2060'); + +/// 옵션 입력 칸: 붙여 넣거나 친 글의 U+00A0 · U+2060 등을 고쳐 받는다 ([cleanOptionText], 관리자 지시 10/20). +/// 화면의 묶은 옵션 글 ([unbreakableOption]) 이나 다른 곳의 글을 붙여 넣어도 rsync 가 다르게 읽지 않게 +class OptionTextFormatter extends TextInputFormatter { + const OptionTextFormatter(); + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + final text = cleanOptionText(newValue.text); + if (text == newValue.text) return newValue; + // 커서 · 고른 곳은 앞부분을 고친 길이로 옮긴다 + int at(int i) => i < 0 ? i : cleanOptionText(newValue.text.substring(0, i.clamp(0, newValue.text.length))).length; + return TextEditingValue( + text: text, + selection: TextSelection(baseOffset: at(newValue.selection.baseOffset), extentOffset: at(newValue.selection.extentOffset)), + ); + } +} class FilterLimitNote extends StatelessWidget { final List unsupported; diff --git a/test/unbreakable_option_test.dart b/test/unbreakable_option_test.dart index d2678ee..298119d 100644 --- a/test/unbreakable_option_test.dart +++ b/test/unbreakable_option_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/rsync_filter.dart'; +import 'package:jj_mkvmaker/core/sync_tools.dart'; import 'package:jj_mkvmaker/ui/sync_preview_view.dart'; /// 관리자 지시 (10/20, 28 지적): 경고 글 안의 옵션 (--filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리던 것 @@ -15,9 +17,36 @@ void main() { for (final o in ['--filter=H a.txt', '-f"- *.log"', '--files-from=list/a b.txt', '-C']) { final u = unbreakableOption(o); expect(widest(u), closeTo(widest(u, min: false), 0.01), reason: o); - expect(u.replaceAll('⁠', '').replaceAll(' ', ' '), o, reason: '보이는 글은 그대로'); + expect(u.replaceAll('\u2060', '').replaceAll('\u00A0', ' '), o, reason: '보이는 글은 그대로'); } // 묶지 않은 글은 줄이 갈릴 수 있다 (시험이 실제로 무언가를 보는지) expect(widest('--filter=H a.txt'), lessThan(widest('--filter=H a.txt', min: false))); }); + + // 관리자 지시 (10/20): 묶은 글을 복사해 옵션 칸에 붙여 넣어도 rsync 가 같은 인수로 읽는다 + test('묶은 옵션 글 · 다른 곳의 글을 붙여 넣어도 인수는 원래대로', () { + const o = '-a --delete --filter="H a.txt" -f"- *.log"'; + final pasted = unbreakableOption(o); + expect(pasted, isNot(o)); + expect(cleanOptionText(pasted), o); + expect(splitOptions(pasted), splitOptions(o)); + expect(splitOptions(pasted), ['-a', '--delete', '--filter=H a.txt', '-f- *.log']); + // 따옴표 안의 줄바꿈 없는 빈칸도 보통 빈칸 (거르기 규칙 "H a.txt" 가 그대로) + expect(RsyncFilter.parse(pasted).unsupported, RsyncFilter.parse(o).unsupported); + expect(RsyncFilter.parse(pasted).excluded('old.log', isDir: false), isTrue); + // 웹 · 문서에서 온 글 (U+202F · U+200B · U+FEFF) + expect(splitOptions('\uFEFF-av\u202F--delete\u200B'), ['-av', '--delete']); + }); + + testWidgets('옵션 칸 (OptionTextFormatter): 붙여 넣은 묶은 글을 고쳐 받고 커서는 끝에', (tester) async { + final ctl = TextEditingController(); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: TextField(controller: ctl, inputFormatters: const [OptionTextFormatter()])))); + final pasted = unbreakableOption('--filter=H a.txt'); + await tester.enterText(find.byType(TextField), pasted); + await tester.pump(); + expect(ctl.text, '--filter=H a.txt'); + expect(ctl.selection.baseOffset, ctl.text.length); + ctl.dispose(); + }); }