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(); + }); }