From 067746223b72e6e76ead49169899747f0cbf8368 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:51:34 +0900 Subject: [PATCH] =?UTF-8?q?E5c:=20=EC=95=95=EC=B6=95=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EC=97=AC=EB=8A=94=20=EA=B8=B8=20=C2=B7=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=EC=84=9C=20=EC=88=A8=EA=B8=B0=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20Rsync=20=EC=8B=A4=ED=96=89=EC=9D=84=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EB=A1=9C=20+=20=EC=A0=90=EA=B2=80=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 승인 E5 계획 (E5a~E5e). 동작은 바꾸지 않음 - lib/app/archive_browse.dart ArchiveBrowser: 압축을 폴더처럼 (읽기 · 바뀌면 다시 읽고 잊음 알림 · 안의 목록 · 하나 꺼내기), archive_ops 를 부르기만, 7-Zip 실행 파일은 부르는 쪽이 줌 - lib/app/file_opener.dart openRouteFor: 열 때 어느 길 (플레이어 · 압축 풀기 · 그림 · PDF · ZIP · HWP · 문서 · 바깥 앱) - 순수 - lib/core/app_hidden.dart HiddenList: 목록에서 숨긴 경로 (대소문자 · 옮기면 따라감 · 실제로 없어진 것만 뺌) - lib/app/rsync_runner.dart RsyncRun: Rsync 화면 실행 (막는 조건 · 작업 · 옵션 (⇄ 의 ← 는 지우지 않음 · -u) · 미리 보기 · 차례로 실행) - sync_preview · sync_tools · rsync_filter · CopyCenter 를 부르기만 (modular_sync 는 rc2 그대로라 겹침 없음, 15 의 RsyncPlan · RsyncLaunch 가 생기면 그것을 부르게 맞춤) - lib/ui/explorer/rsync_run_dialog.dart: Rsync 확인 창 위젯 (RsyncRun 만 부름) - integration_test/explorer_use_check_test.dart: 탐색기 점검 통합 시험 (관리자 승인) - 시험 폴더 이름 · 파일 시각 고정 (픽셀 비교 기준), 시험 폴더만 씀 - explorer_page 6,267 → 5,922 확인: analyze 0 · 전체 단위 2회 통과 1,329 · 건너뜀 18 · 실패 0 (E5b 에서 고친 explorer_test "X-plore e: Android 앱 휴지통 - 지우기 = 휴지통으로 · [되돌리기] · [휴지통] 화면에서 되살리기" 도 이 2회 통과) · 점검 통합 시험 (1920 · 960 · 412) 2회 통과. 캡처 픽셀 비교 (고정 기준 = E5a 를 두 번 찍은 것, 기준끼리 87장 같음): 90장 중 87 · 88장 픽셀까지 같음, 다른 2 · 3장은 기준끼리도 다른 움직이는 장면 (이동 확인 · 선택 끝) 약속 시험 (실제 시험 폴더 · 실제 ZIP · 실제 폴더 비교): test/explorer_modules_e5c_contract_test.dart 5 Co-Authored-By: Claude Opus 5.5 --- integration_test/explorer_use_check_test.dart | 226 ++++++++ lib/app/archive_browse.dart | 148 +++++ lib/app/file_opener.dart | 35 ++ lib/app/rsync_runner.dart | 164 ++++++ lib/core/app_hidden.dart | 61 ++ lib/ui/explorer/rsync_run_dialog.dart | 151 +++++ lib/ui/explorer_page.dart | 531 +++--------------- test/explorer_modules_e5c_contract_test.dart | 121 ++++ 8 files changed, 999 insertions(+), 438 deletions(-) create mode 100644 integration_test/explorer_use_check_test.dart create mode 100644 lib/app/archive_browse.dart create mode 100644 lib/app/file_opener.dart create mode 100644 lib/app/rsync_runner.dart create mode 100644 lib/core/app_hidden.dart create mode 100644 lib/ui/explorer/rsync_run_dialog.dart create mode 100644 test/explorer_modules_e5c_contract_test.dart diff --git a/integration_test/explorer_use_check_test.dart b/integration_test/explorer_use_check_test.dart new file mode 100644 index 0000000..b95dbf1 --- /dev/null +++ b/integration_test/explorer_use_check_test.dart @@ -0,0 +1,226 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart' show kSecondaryMouseButton; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import '../test/support/fake_recycle_bin.dart'; +import 'support/temp_dir.dart'; + +/// 탐색기 사용 점검 (관리자 c5 지시 10/22, 분석용 - 커밋하지 않음): 처음 설정 그대로 사용자처럼 써 보며 단계마다 PNG. +/// 저장 장치는 시험 폴더 둘 (debugVolumes), 휴지통은 가짜 (사용자 휴지통에 흔적을 남기지 않게), 설정은 메모리에만. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_use'); + + for (final (w, h) in [(1920, 1040), (960, 600), (412, 900)]) { + testWidgets('탐색기 사용 ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply('ko', save: false)); + useTestRecycleBin(); + // 캡처 비교가 흔들리지 않게 시험 폴더 이름 · 파일 시각을 고정 (관리자 지시 10/22) - 끝나면 지움 + final tmp = Directory(p.join(Directory.systemTemp.path, 'jj_usechk_fixed_$w')); + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + tmp.createSync(); + addTearDown(() { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + }); + appTempInto(tmp); + final a = Directory(p.join(tmp.path, 'Videos'))..createSync(); + final b = Directory(p.join(tmp.path, 'Backup'))..createSync(); + for (final n in ['Show S01', 'Movies', 'Docs']) { + Directory(p.join(a.path, n)).createSync(); + } + for (var i = 1; i <= 3; i++) { + File(p.join(a.path, 'Show S01', 'ep0$i.mkv')).writeAsBytesSync(List.filled(5000 * i, 0)); + } + File(p.join(a.path, 'Movies', 'film.mp4')).writeAsBytesSync(List.filled(9000, 0)); + File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo'); + File(p.join(a.path, 'report.pdf')).writeAsStringSync('pdf'); + for (final f in tmp.listSync(recursive: true).whereType()) { + f.setLastModifiedSync(DateTime(2026, 10, 22, 9)); + } + ExplorerPage.debugVolumes = () => [(a.path, 'C:'), (b.path, 'D:')]; + addTearDown(() => ExplorerPage.debugVolumes = null); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final shotKey = GlobalKey(); + await t.runAsync(() => t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)), + ))); + Future settle([int n = 12]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + var no = 0; + Future shot(String name) async { + await settle(3); + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + no++; + File(p.join(out, 'use_${w}_${no.toString().padLeft(2, '0')}_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + // 열린 창 (대화 상자 · 메뉴) 의 글을 기록 - 무엇이 떴는지 + void dump(String step) { + final texts = {}; + for (final f in [find.byType(Dialog), find.byType(PopupMenuItem), find.byType(BottomSheet), find.byType(SnackBar)]) { + for (final e in find.descendant(of: f, matching: find.byType(Text)).evaluate()) { + final s = (e.widget as Text).data ?? (e.widget as Text).textSpan?.toPlainText() ?? ''; + if (s.isNotEmpty) texts.add(s); + } + } + // ignore: avoid_print + print('[$w] $step: ${texts.isEmpty ? '(뜬 창 없음)' : texts.join(' | ')}'); + } + + // 한 단계: 실패해도 다음 단계로 (못 찾은 것 자체가 점검 결과) + Future step(String name, Future Function() act) async { + try { + await act(); + await settle(); + dump(name); + } catch (e) { + // ignore: avoid_print + print('[$w] $name: 실패 - ${e.toString().split('\n').first}'); + } + await shot(name); + } + + // 두 창이면 왼쪽 창의 것만 (같은 폴더가 두 창에 보여 오른쪽 것을 누르지 않게) + Finder leftOf(Finder f) { + final half = w / 2; // 규칙 7: 폰 세로도 두 창 + final el = f.evaluate().where((e) => t.getCenter(find.byWidget(e.widget).first).dx < half).toList(); + if (el.isEmpty) throw StateError('왼쪽 창에 없음'); + return find.byWidget(el.first.widget).first; + } + Finder txt(String name) => leftOf(find.text(name)); + Finder row(String name) => find.ancestor(of: txt(name), matching: find.byType(InkWell)).first; + Finder tip(String s) => find.byWidgetPredicate((x) => x is Tooltip && (x.message ?? '').startsWith(s)).first; + // 줄 끝 표시 단추: 이름 글과 같은 높이의 것 + Finder mark(String name) { + final y = t.getCenter(txt(name)).dy; + final half = w / 2; // 규칙 7: 폰 세로도 두 창 + final all = find.byWidgetPredicate((x) => x is IconButton && (x.tooltip ?? '').startsWith('표시')); + final best = all.evaluate().where((e) => t.getCenter(find.byWidget(e.widget)).dx < half).toList() + ..sort((m, n) => (t.getCenter(find.byWidget(m.widget)).dy - y).abs().compareTo((t.getCenter(find.byWidget(n.widget)).dy - y).abs())); + return find.byWidget(best.first.widget); + } + // 대화 상자의 확인 쪽 단추 (마지막 FilledButton) + Future okDialog() => t.tap(find.descendant(of: find.byType(Dialog), matching: find.byType(FilledButton)).last); + void files(String when) { + final left = [ + for (final e in tmp.listSync(recursive: true)) + if (e is File) p.relative(e.path, from: tmp.path) + ]..sort(); + // ignore: avoid_print + print('[$w] 파일 ($when): ${left.join(', ')}'); + } + Finder paneBtn(String s) => find.byWidgetPredicate((x) => x is IconButton && (x.tooltip ?? '').startsWith(s)).first; + + await settle(30); + await shot('처음'); + dump('처음'); + await step('Show S01 누르기', () => t.tap(txt('Show S01'))); + await step('Show S01 다시 누르기 (접히나)', () => t.tap(txt('Show S01'))); + if (find.text('ep01.mkv').evaluate().isEmpty) await t.tap(txt('Show S01')); + await settle(); + await step('ep01 한 번 누르기 (선택)', () => t.tap(txt('ep01.mkv'))); + // 규칙 4 (E3): 표시는 선택 모드에서만 - [선택] 을 켜고 표시 + Future selectOn() async { + if (find.text('선택 끝').evaluate().isEmpty) await t.tap(tip('선택')); + } + await step('선택 켜기', selectOn); + await step('ep02 표시 단추', () => t.tap(mark('ep02.mkv'))); + await step('ep03 표시 단추', () => t.tap(mark('ep03.mkv'))); + await step('복사 단추', () => t.tap(tip('복사'))); + await step('복사 확인 누르기', () async { + if (find.byType(Dialog).evaluate().isNotEmpty) { + await okDialog(); + } else { + await t.tap(txt('D:')); + await settle(); + await t.tap(tip('붙여넣기')); + await settle(); + if (find.byType(Dialog).evaluate().isNotEmpty) await okDialog(); + await settle(); + await t.tap(txt('C:')); + } + }); + await settle(20); + files('복사 뒤'); + await step('선택 모드 켜기 (켜져 있으면 그대로)', selectOn); + await step('선택 모드 - Show S01 누르기', () => t.tap(txt('Show S01'))); + await step('선택 모드 - 안의 ep01 누르기', () => t.tap(txt('ep01.mkv'))); + await step('선택 모드 - Show S01 다시 누르기', () => t.tap(txt('Show S01'))); + await step('선택 단추 (끄기)', () => t.tap(tip('선택'))); + await step('ep03 오른쪽 클릭', () => t.tap(row('ep03.mkv'), buttons: kSecondaryMouseButton)); + await step('메뉴 이름 변경', () => t.tap(find.text('이름 변경').last)); + await step('새 이름 넣고 확인', () async { + await t.enterText(find.descendant(of: find.byType(Dialog), matching: find.byType(TextField)), 'ep03_new.mkv'); + await settle(2); + await okDialog(); + }); + files('이름 바꾼 뒤'); + await step('ep01 오른쪽 클릭', () => t.tap(row('ep01.mkv'), buttons: kSecondaryMouseButton)); + await step('메뉴 삭제', () => t.tap(find.text('삭제').last)); + await step('삭제 확인 누르기', okDialog); + files('지운 뒤'); + await step('Movies 누르기', () => t.tap(txt('Movies'))); + await step('창 뒤로', () => t.tap(paneBtn('뒤로'))); + await step('창 앞으로', () => t.tap(paneBtn('앞으로'))); + await step('선택 켜기 2', selectOn); + await step('film 표시 단추', () => t.tap(mark('film.mp4'))); + await step('이동 단추', () => t.tap(tip('이동'))); + await step('이동 확인 누르기', () async { + if (find.byType(Dialog).evaluate().isNotEmpty) { + await okDialog(); + } else { + await t.tap(txt('D:')); + await settle(); + await t.tap(tip('붙여넣기')); + } + }); + await settle(20); + files('이동 뒤'); + await step('notes 누르고 Space (TC 식 고르기)', () async { + await t.tap(txt('notes.txt')); + await settle(4); + await t.sendKeyEvent(LogicalKeyboardKey.space); + }); + await step('Insert', () => t.sendKeyEvent(LogicalKeyboardKey.insert)); + await step('report 누르고 Enter (열기 대신 무엇이)', () async { + await t.tap(txt('report.pdf')); + await settle(4); + }); + // 끝 상태: 실제 파일 + final left = [ + for (final e in tmp.listSync(recursive: true)) + if (e is File) p.relative(e.path, from: tmp.path) + ]..sort(); + // ignore: avoid_print + print('[$w] 끝 파일: ${left.join(', ')}'); + await t.pumpWidget(const SizedBox()); + await settle(4); + }); + } +} diff --git a/lib/app/archive_browse.dart b/lib/app/archive_browse.dart new file mode 100644 index 0000000..cbc750d --- /dev/null +++ b/lib/app/archive_browse.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../core/archive_ops.dart'; +import '../core/file_ops.dart' show FileEntry, NameConflict, isSameOrInside, samePath; +import '../core/vfs.dart' show isDav; + +/// 약속 (E5c): 압축 (ZIP · 7z · RAR) 을 폴더처럼 본다 - 읽기 전용. 압축 읽기 · 풀기는 archive_ops 를 부르기만 한다. +/// - [read]: 압축 경로 → [ArchiveRead] (읽음 · 못 읽음의 까닭). 한 번 읽은 것은 압축 파일의 크기 · 바뀐 때가 같으면 다시 읽지 않고, +/// 바뀌었으면 다시 읽고, 없어졌으면 잊는다. +/// - [list]: 압축 안 폴더 → 바로 아래 항목 (목록에 폴더 이름이 없어도 경로에서 폴더를 만든다, 위험한 이름 · 링크는 뺀다). +/// - [archiveOf] · [inside] · [innerName]: 경로가 든 (펼친) 압축 · 압축 안인지 · 압축 안 이름 ("폴더/파일"). +/// - [extractOne]: 압축 안 파일 하나를 [tempRoot] 아래로 꺼냄 → 꺼낸 경로 또는 못 꺼낸 까닭. +/// - 7-Zip 실행 파일은 부르는 쪽이 준다 ([sevenZip], 없으면 null - 7z · RAR 는 못 읽음). +/// - 다시 쓰는 곳: 탐색기 트리 · 찾기 (압축 안) · 보관함 미리 보기. +enum ArchiveRead { ok, gone, noTool, password, failed } + +class ArchiveBrowser { + ArchiveBrowser(this.sevenZip, {this.onForget}); + + final Future Function() sevenZip; + + /// 읽어 둔 압축을 잊을 때 (바뀜 · 없어짐) - 부르는 쪽이 그 안의 읽어 둔 목록을 지우게 + final void Function(String arc)? onForget; + + /// 펼쳐 본 압축의 목록 (압축 경로 → 항목) + final _entries = >{}; + + /// 압축을 읽은 때의 (크기, 바뀐 때) + final _stamps = {}; + + /// [read] 가 [ArchiveRead.failed] 일 때의 까닭 (화면에 보일 말) + Object? lastError; + + /// 트리에서 펼칠 수 있는 압축 (이 기기의 파일) + static bool canBrowse(FileEntry e) => !e.isDir && !isDav(e.path) && archiveKindOf(e.path) != null; + + /// 읽어 둔 압축인지 + bool contains(String path) => _entries.containsKey(path); + + /// [path] 가 들어 있는 (읽어 둔) 압축 - 압축 자신도 + String? archiveOf(String path) { + String? best; + for (final a in _entries.keys) { + if ((samePath(path, a) || isSameOrInside(path, a)) && (best == null || a.length > best.length)) best = a; + } + return best; + } + + /// 압축 안의 항목인지 (압축 파일 자신은 아님) + bool inside(String path) { + final a = archiveOf(path); + return a != null && !samePath(a, path); + } + + /// 압축 안 경로 → 압축 안의 이름 ("폴더/파일") + String innerName(String archive, String path) => p.relative(path, from: archive).replaceAll('\\', '/'); + + /// [arc] 의 읽어 둔 목록을 잊는다 (다시 읽게) + void forget(String arc) { + _entries.remove(arc); + _stamps.remove(arc); + onForget?.call(arc); + } + + static Future<(int, DateTime)?> _stampOf(String path) async { + try { + final st = await File(path).stat(); + if (st.type == FileSystemEntityType.notFound) return null; + return (st.size, st.modified); + } catch (_) { + return null; + } + } + + Future read(String path) async { + final stamp = await _stampOf(path); + if (_entries.containsKey(path)) { + if (stamp != null && stamp == _stamps[path]) return ArchiveRead.ok; + forget(path); + if (stamp == null) return ArchiveRead.gone; + } + try { + if (archiveKindOf(path) == ArchiveKind.zip) { + _entries[path] = await listZip(path); + } else { + final exe = await sevenZip(); + if (exe == null) return ArchiveRead.noTool; + _entries[path] = await list7z(exe, path); + } + if (stamp != null) _stamps[path] = stamp; + return ArchiveRead.ok; + } on ArchivePasswordException { + return ArchiveRead.password; + } catch (e) { + lastError = e; + return ArchiveRead.failed; + } + } + + /// 압축 안 폴더 [dir] 의 바로 아래 항목 + List list(String dir) { + final arc = archiveOf(dir)!; + final rel = samePath(dir, arc) ? '' : innerName(arc, dir); + final out = {}; + for (final a in _entries[arc]!) { + if (a.isLink) continue; + final n = a.name.replaceAll('\\', '/').replaceAll(RegExp(r'^(\./)+|/+$'), ''); + if (n.isEmpty || unsafeArchivePath(n) != null) continue; + if (rel.isNotEmpty && !n.startsWith('$rel/')) continue; + final rest = rel.isEmpty ? n : n.substring(rel.length + 1); + if (rest.isEmpty) continue; + final first = rest.split('/').first; + final folder = rest.contains('/') || a.isDir; + final path = p.joinAll([arc, ...(rel.isEmpty ? first : '$rel/$first').split('/')]); + final old = out[first]; + if (old == null || (folder && !old.isDir)) { + out[first] = FileEntry(path, isDir: folder, size: folder ? 0 : a.size, modified: a.modified ?? DateTime(0)); + } + } + return out.values.toList(); + } + + /// 압축 안 파일 하나를 꺼낸다 → (꺼낸 경로, 못 꺼낸 까닭). 비밀번호가 걸렸으면 ([ArchiveRead.password]), 7-Zip 이 없으면 + /// ([ArchiveRead.noTool]), 실패는 ([ArchiveRead.failed], [lastError]), 꺼냈는데 파일이 없으면 ([ArchiveRead.gone]) + Future<(String?, ArchiveRead)> extractOne(String path, String tempRoot) async { + final arc = archiveOf(path)!; + final inner = innerName(arc, path); + final dest = p.join(tempRoot, 'jj_arcview', '${arc.hashCode.toUnsigned(32)}'); + final plan = planExtract(_entries[arc]!, names: [inner]); + if (plan.encrypted) return (null, ArchiveRead.password); + try { + if (archiveKindOf(arc) == ArchiveKind.zip) { + await extractZipPlan(arc, dest, plan, conflict: NameConflict.overwrite); + } else { + final exe = await sevenZip(); + if (exe == null) return (null, ArchiveRead.noTool); + await extract7zPlan(exe, arc, dest, plan, conflict: NameConflict.overwrite); + } + } catch (e) { + lastError = e; + return (null, ArchiveRead.failed); + } + final out = p.joinAll([dest, ...inner.split('/')]); + return File(out).existsSync() ? (out, ArchiveRead.ok) : (null, ArchiveRead.gone); + } +} diff --git a/lib/app/file_opener.dart b/lib/app/file_opener.dart new file mode 100644 index 0000000..fd66fca --- /dev/null +++ b/lib/app/file_opener.dart @@ -0,0 +1,35 @@ +import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf; +import '../core/hwp_source.dart' show hwpExtensions; +import '../core/playlist.dart' show isVideoFile; +import '../core/reader_sources.dart' show extOf, zipExtensions; +import 'components.dart' show AppComponent; + +/// 약속 (E5c): 파일을 "열" 때 어느 길로 여는지 고른다 ([openRouteFor]). 순수 함수 - 여는 일은 부르는 화면이 한다. +/// 입력 = 경로 · 켠 컴포넌트 · 그림 확장자 · 기기 · HWP 엔진이 있는지, 출력 = [OpenRoute]. 오류 없음. +/// 차례: 동영상 → 내장 플레이어 · 7z/RAR → 압축 풀기 · (보기 컴포넌트) 그림 · PDF · ZIP/CBZ → +/// (Android · 보기 · 엔진) HWP → 앱 안 보기 (못 열면 바깥 앱) · (Windows · 문서 컴포넌트) 문서 미리 보기 → 바깥 앱. +/// - 다시 쓰는 곳: 탐색기 · 다운로드 화면 · 보관함 · 찾기 결과. +enum OpenRoute { player, extractArchive, images, pdf, zip, hwp, docPreview, external } + +OpenRoute openRouteFor( + String path, { + required List components, + required List imageExts, + required bool android, + required bool windows, + required bool hwpEngine, +}) { + if (isVideoFile(path)) return OpenRoute.player; + final kind = archiveKindOf(path); + if (kind == ArchiveKind.sevenZip || kind == ArchiveKind.rar) return OpenRoute.extractArchive; + final ext = extOf(path); + final viewer = components.contains('viewer'); + if (viewer) { + if (imageExts.contains(ext)) return OpenRoute.images; + if (ext == 'pdf') return OpenRoute.pdf; + if (zipExtensions.contains(ext)) return OpenRoute.zip; + } + if (android && viewer && hwpExtensions.contains(ext) && hwpEngine) return OpenRoute.hwp; + if (components.contains('docs') && windows && AppComponent.docExtensions.contains(ext)) return OpenRoute.docPreview; + return OpenRoute.external; +} diff --git a/lib/app/rsync_runner.dart b/lib/app/rsync_runner.dart new file mode 100644 index 0000000..047a2bf --- /dev/null +++ b/lib/app/rsync_runner.dart @@ -0,0 +1,164 @@ +import '../core/file_ops.dart' show isSameOrInside, samePath, transferProblem; +import '../core/rsync_filter.dart'; +import '../core/sync_preview.dart'; +import '../core/sync_tools.dart' show OptionProblem, optionProblems, withUpdateOption; +import '../l10n/tr.dart'; +import 'copy_center.dart'; +import 'settings.dart' show CopyTask; +import 'transfer_job.dart'; + +/// 약속 (E5c): Rsync 화면의 [→] · [←] · [⇄] 실행. 화면 (확인 창) 은 이 객체만 부른다. +/// rsync 옵션 · 미리 보기 · 실행은 sync_preview · sync_tools · rsync_filter · CopyCenter 를 부르기만 한다 (rsync 를 흉내 내지 않음). +/// - [RsyncRun.prepare]: 왼쪽 · 오른쪽 고른 폴더 · 방향 → 실행할 쌍 또는 막는 이유 (글). 원본 · 대상이 없어졌으면 [missing]. +/// 막는 것: 고르지 않음 · 대상이 원본과 같거나 원본 안 · (설정이 막으면) 원본이 대상 안 · 같은 쌍이 도는 중 · +/// 원본 · 대상 없음 · 원본이 대상 안인데 지우기 (34 · 23 · 105). +/// - 확인 창에서 [removeSource] (원본 파일 지우기) · [prune] 을 바꾸면 [tasks] 가 그 작업으로 바뀐다 (96: 창 · 미리 보기 · 실행이 같은 옵션). +/// - ⇄ 는 → 다음에 ← (95), ← 에서는 지우지 않는다 · 받는 쪽이 새것이면 덮지 않음 (-u). +/// - [execute]: 확인 창에 보인 옵션 그대로 차례로 실행 (앞의 것이 실패 · 취소되면 뒤는 하지 않음). 출력 = 시작한 작업들 · +/// 시작하지 못한 이유. +/// - 15 의 RsyncPlan · RsyncLaunch (modular_sync) 가 생기면 그것을 부르게 맞춘다. +class RsyncRun { + RsyncRun._(this.center, this.l, this.r, this.runs, this.both, this.nested, this.optionMove, this.viaDav, this.copySkipNewer) + : removeSource = optionMove && !both { + tasks = _shown(removeSource); + } + + final CopyCenter center; + final String l, r; + final List<(String, String)> runs; + final bool both, nested, viaDav, copySkipNewer; + + /// 환경 설정 (또는 기억한) 옵션에 원본 지우기가 있는지 (178) + final bool optionMove; + + /// 한 방향만: 원본 파일 지우기 (--remove-source-files) · 끝난 뒤 원본 정리 + bool removeSource; + String prune = 'keep'; + + /// 확인 창에 보이고 실행할 작업 (쌍마다) + late List tasks; + + List _shown(bool move) => + [for (final (src, dst) in runs) center.peek([src], dst, contents: true, method: 'rsync', move: move)]; + + static ({RsyncRun? run, String? problem, bool missing}) prepare( + CopyCenter center, { + required String? l, + required String? r, + required bool right, + required bool left, + required bool allowInnerToOuter, + required Iterable jobs, + required bool viaDav, + required bool copySkipNewer, + }) { + ({RsyncRun? run, String? problem, bool missing}) no(String why, {bool missing = false}) => + (run: null, problem: why, missing: missing); + if (l == null || r == null) return no(tr('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.')); + final runs = [if (right) (l, r), if (left) (r, l)]; + if (runs.any((x) => isSameOrInside(x.$2, x.$1))) { + return no(tr('대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.')); + } + final nested = runs.any((x) => isSameOrInside(x.$1, x.$2)); + if (nested && !allowInnerToOuter) return no(tr('원본이 대상 안에 있습니다 (환경 설정에서 막아 둠). 다른 폴더를 고르세요.')); + if (jobs.any((j) => + !j.finished && + runs.any((x) => + (samePath(j.sources.first, x.$1) && samePath(j.dest, x.$2)) || + (samePath(j.sources.first, x.$2) && samePath(j.dest, x.$1))))) { + return no(tr('이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.')); + } + for (final (src, dst) in runs) { + final problem = transferProblem([src], dst, move: false); + if (problem != null) return no(problem, missing: true); + } + final both = right && left; + final optionMove = runs + .map((x) => center.peek([x.$1], x.$2, contents: true, method: 'rsync', move: false)) + .any((t) => optionsRemoveSource(t.options)); + final run = RsyncRun._(center, l, r, runs, both, nested, optionMove, viaDav, copySkipNewer); + if (run.nestedDelete()) return no(nestedDeleteMessage); + return (run: run, problem: null, missing: false); + } + + static String get nestedDeleteMessage => + tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).'); + + /// 원본 파일 지우기를 바꿈 - 작업 (옵션) 이 그 작업으로 바뀐다 + void setRemoveSource(bool v) { + removeSource = v; + tasks = _shown(v); + } + + String opts(CopyTask t) { + final o = withoutSourceRemovingOptions(t.options); + return both ? withUpdateOption(o) : o; + } + + /// 95: ⇄ 는 → 다음에 ← (차례로). ← 에서는 지우지 않는다 (→ 가 방금 복사한 것을 지우지 않게) + String runOpts(int i) => both && i > 0 ? withoutDeleteOptions(opts(tasks[i])) : opts(tasks[i]); + + /// 15 검토 F2 · 관리자 지시 (10/19): 미리 볼 수 없는 거르기 규칙 + List filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList(); + + /// 관리자 지시 (10/20): 고쳐 읽어도 알아볼 수 없는 옵션 (있으면 [실행] 을 막음) + List badOptions() { + final seen = {}; + return [for (final t in tasks) ...optionProblems(opts(t)).where((x) => seen.add(x.text))]; + } + + /// 93: 지우는 실행 (--delete 등) + bool deletes() => tasks.any((t) => optionsDelete(opts(t))); + + /// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 + bool nestedDelete() => nested && deletes(); + + /// 72 · 177 · 229: 실행 전 비교 (rsync 로 맞추면 옵션의 거르기도, WebDAV 등은 앱이 직접) + Future> preview() async => both + ? previewBoth(l, r, delete: deletes(), filter: RsyncFilter.parse(opts(tasks.first))) + : [ + for (final t in tasks) + ...await previewSync(t.sources.first, t.dest, + toRight: t.sources.first == l, + delete: deletes(), + update: optionsUpdate(opts(t)) || (viaDav && copySkipNewer), + skipLinks: viaDav, + filter: RsyncFilter.parse(opts(t))), + ]; + + /// 확인 창에 보인 옵션으로 차례로 실행. [onStarted] 는 작업이 시작될 때마다, [alive] 가 false 면 멈춘다 + Future<({List jobs, String? notStarted})> execute({ + required String? rsyncExe, + required void Function(TransferJob job) onStarted, + required bool Function() alive, + void Function(String line)? note, + }) async { + final shownOpts = [for (var i = 0; i < tasks.length; i++) runOpts(i)]; + var run = [ + for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource), + ]; + if (removeSource) { + run = [for (final t in run) t.copyWith(prune: prune)]; + for (final t in run) { + await center.update(t); + } + } + final jobs = []; + for (var i = 0; i < run.length; i++) { + note?.call(trf('rsync 시작 ({0}): {1} → {2}', [tr('Rsync 화면 확인 창의 [실행]'), run[i].sources.first, run[i].dest])); + // 218: 확인 창에 보인 그대로 rsync 로 (WebDAV 가 끼면 앱이 직접) + final j = await center.start(run[i].copyWith(options: shownOpts[i], method: 'rsync'), rsyncExe: viaDav ? null : rsyncExe); + if (!alive()) break; + if (j == null) { + final why = transferProblem(run[i].sources, run[i].dest, move: run[i].move, contents: run[i].contents); + note?.call(trf('rsync 시작하지 못함: {0}', [why ?? ''])); + return (jobs: jobs, notStarted: why ?? tr('rsync 를 실행할 수 없습니다')); + } + jobs.add(j); + onStarted(j); + await j.done; + if (j.error != null) break; + } + return (jobs: jobs, notStarted: null); + } +} diff --git a/lib/core/app_hidden.dart b/lib/core/app_hidden.dart new file mode 100644 index 0000000..16e2b97 --- /dev/null +++ b/lib/core/app_hidden.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'file_ops.dart' show isSameOrInside, samePath; +import 'vfs.dart' show isDav, vExists, vJoin; + +/// 약속 (E5c): 탐색기 "목록에서 숨기기" 의 목록 규칙 (파일은 그대로 - 앱 설정의 경로 목록). +/// - [isHidden]: 목록 · 경로 → 숨긴 것인지 (Windows 대소문자 무시, 같은 목록이면 열쇠를 다시 만들지 않음). +/// - [follow]: 옮기거나 이름을 바꾼 것 (원래 → 새 경로, 그 안의 것도) 은 새 경로로, 지운 것 (그 안의 것도 - 실제로 없어진 +/// 것만, [exists] 로 확인) 은 목록에서 뺀다 (관리자 결정 10/18). 출력 = 새 목록, 바뀐 것이 없으면 null. 같은 경로는 한 번만. +/// - 다시 쓰는 곳: 탐색기 트리 · Rsync 트리 · 찾기 결과. +class HiddenList { + List? _from; + Set _keys = const {}; + + /// 숨긴 목록에서 같은 경로를 찾는 열쇠 + static String keyOf(String x) { + if (isDav(x) || x.contains('://')) return x; + final n = p.normalize(x); + return Platform.isWindows ? n.toLowerCase() : n; + } + + bool isHidden(List list, String path) { + if (list.isEmpty) return false; + if (!identical(list, _from)) { + _from = list; + _keys = {for (final x in list) keyOf(x)}; + } + return _keys.contains(keyOf(path)); + } + + static Future?> follow(List list, + {Iterable<(String, String)> moved = const [], + Iterable gone = const [], + Future Function(String path) exists = vExists}) async { + if (list.isEmpty || (moved.isEmpty && gone.isEmpty)) return null; + final out = []; + var changed = false; + for (final h in list) { + if (gone.any((g) => isSameOrInside(h, g)) && !await exists(h)) { + changed = true; + continue; + } + var now = h; + for (final (from, to) in moved) { + if (samePath(h, from)) { + now = to; + break; + } + if (isSameOrInside(h, from)) { + now = vJoin(to, isDav(h) ? h.substring(from.length).replaceAll('\\', '/').replaceFirst(RegExp('^/+'), '') : p.relative(h, from: from)); + break; + } + } + if (now != h) changed = true; + if (!out.any((x) => keyOf(x) == keyOf(now))) out.add(now); + } + return changed ? out : null; + } +} diff --git a/lib/ui/explorer/rsync_run_dialog.dart b/lib/ui/explorer/rsync_run_dialog.dart new file mode 100644 index 0000000..0ac68ea --- /dev/null +++ b/lib/ui/explorer/rsync_run_dialog.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; + +import '../../app/rsync_runner.dart'; +import '../../core/sync_preview.dart' show PreviewAction, PreviewItem; +import '../../core/vfs.dart' show vDisplay; +import '../../l10n/tr.dart'; +import '../monitor_page.dart' show PruneChoice; +import '../path_label.dart' show shortPath; +import '../sync_preview_view.dart'; +import '../theme.dart'; + +/// Rsync 화면의 실행 확인 창 (E5c) - 실행할 것 · 옵션 · 원본 지우기 · 미리 보기. 규칙은 [RsyncRun] 이 가진다. +/// [실행] 이면 true, [취소] 면 false, 닫으면 null. +Future showRsyncRunDialog(BuildContext context, RsyncRun run) { + // 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에) + List? preview; + Object? previewError; + // 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때) + var filterAck = false; + return showDialog( + context: context, + builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog( + scrollable: true, + // 110: 버튼이 좁아 세로로 쌓여도 [취소] 가 먼저 (아래 · 위가 바뀌어 [취소] 자리에 [실행] 이 오지 않게) + actionsOverflowDirection: VerticalDirection.down, + title: Text(run.both ? tr('rsync 양쪽 (⇄)') : 'rsync'), + content: SizedBox( + width: 560, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (final t in run.tasks) ...[ + // 109: 끝 폴더가 보이게 짧게, 전체 경로는 작게 + Text(shortPath(t.sources.first), style: const TextStyle(fontWeight: FontWeight.w600)), + Text('→ ${shortPath(t.dest)}', style: const TextStyle(fontWeight: FontWeight.w600)), + Text('${vDisplay(t.sources.first)}/ → ${vDisplay(t.dest)}/', + style: TextStyle(fontSize: 11, color: JjColors.textDim)), + // 111: 이 쌍은 처음 실행 때 기억한 옵션을 쓴다 (환경 설정의 rsync 옵션을 바꿔도 그대로) + if (run.center.tasks.any((x) => x.id == t.id)) + Text(tr('이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)'), + style: TextStyle(fontSize: 11, color: JjColors.textDim)), + Text('rsync ${run.runOpts(run.tasks.indexOf(t))}${run.removeSource ? ' --remove-source-files' : ''}' + '${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}', + style: TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), + const SizedBox(height: 10), + ], + Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + // 222: 이동이면 동작대로 (예전에는 이동일 때도 "복사 · 원본 폴더는 그대로" 라고 썼다) + if (run.nested) + Text( + run.removeSource && !run.both + ? tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래로 옮겨집니다 (원본 파일은 지워지고, 원본 폴더는 아래에서 고른 대로).') + : tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (원본 폴더는 그대로 남음).'), + style: TextStyle(fontSize: 12, color: JjColors.warning)), + // 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178) + if (run.both) ...[ + Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + if (run.optionMove) + Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'), + style: TextStyle(fontSize: 12, color: JjColors.warning)), + ] else ...[ + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: run.removeSource, + onChanged: (v) => set(() { + run.setRemoveSource(v ?? false); + preview = null; + previewError = null; + }), + title: Text(keepOptionsTogether(tr('원본 파일 지우기 (--remove-source-files)'))), + subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')), + ), + if (run.removeSource) ...[ + // 178: 원본이 지워진다는 것을 분명히 (환경 설정 옵션 때문에 켜졌으면 그렇다고) + Text( + run.optionMove + ? tr('원본 파일이 지워집니다 (이동). 환경 설정의 rsync 옵션에 --remove-source-files 가 있어 켜 두었습니다 - 끄면 이번에는 원본을 남깁니다.') + : tr('원본 파일이 지워집니다 (이동).'), + style: TextStyle(fontSize: 12, color: JjColors.warning, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 6), + PruneChoice(value: run.prune, onChanged: (v) => set(() => run.prune = v)), + ], + ], + // 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐) + const SizedBox(height: 8), + if (run.badOptions().isNotEmpty) OptionProblemNote(run.badOptions()), + // 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김) + if (run.badOptions().isEmpty && run.filterLimits().isNotEmpty) + FilterLimitNote(unsupported: run.filterLimits(), deletes: run.deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)), + SyncPreviewView( + // 옵션이 바뀌면 (원본 파일 지우기) 다시 비교 + key: ValueKey([for (var i = 0; i < run.tasks.length; i++) run.runOpts(i)].join('|')), + // 177: rsync 로 맞추면 옵션의 거르기 (--exclude 등) 도 비교에 (WebDAV 는 앱이 직접 - 거르기를 따르지 않음) + // 177 · 229: rsync 로 맞추면 옵션의 거르기도 비교에 (WebDAV 등은 앱이 직접) + compute: run.preview, + // 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 + onStart: () => set(() { + preview = null; + previewError = null; + }), + onResult: (items) => set(() { + preview = items; + previewError = null; + }), + onError: (e) => set(() { + preview = null; + previewError = e; + }), + ), + if (run.nestedDelete()) + Text(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).'), + style: TextStyle(fontSize: 12, color: JjColors.alert, fontWeight: FontWeight.w600)), + // 110: 이 줄은 늘 같은 자리를 차지한다 (글이 사라져 창 높이가 바뀌면 버튼이 움직인다) + SizedBox( + height: 34, + child: run.deletes() && preview == null + ? Text( + previewError != null + ? tr('지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.') + : tr('지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.'), + style: TextStyle(fontSize: 12, color: JjColors.warning), + ) + : null, + ), + const SizedBox(height: 4), + if (run.viaDav) + Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).'), + style: TextStyle(fontSize: 12, color: JjColors.accent)), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + Builder(builder: (_) { + final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0; + return FilledButton( + style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, + onPressed: run.nestedDelete() || run.badOptions().isNotEmpty || (run.deletes() && preview == null) || (run.deletes() && run.filterLimits().isNotEmpty && !filterAck) + ? null + : () => Navigator.pop(ctx, true), + // 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨). + // 지울 수는 위의 빨간 글 "받는 쪽에서 N개가 지워집니다" 로 보인다 + child: Text(run.removeSource ? tr('이동') : tr('실행')), + ); + }), + ], + )), + ); +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 407c7ea..b79c86c 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -14,7 +14,7 @@ import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/copy_center.dart'; import '../app/live_sync.dart'; -import '../app/settings.dart' show CopyTask, FolderBookmark; +import '../app/settings.dart' show FolderBookmark; import '../app/transfer_job.dart'; import '../core/selection.dart'; import '../core/secret_gate.dart'; @@ -40,7 +40,6 @@ import 'folder_picker.dart' show pickFolderOrDav; import 'trash_page.dart'; import 'dav_external.dart'; import '../platform/windows/recycle_bin.dart'; -import '../core/rsync_filter.dart'; import '../core/sync_preview.dart'; import 'path_label.dart'; import 'progress_dialog.dart'; @@ -51,7 +50,6 @@ import 'smb_settings.dart'; import 'webdav_settings.dart'; import 'reader_page.dart'; import '../app/component_store.dart'; -import '../app/components.dart'; import 'zip_page.dart'; import '../core/reader_sources.dart'; import '../core/hwp_source.dart'; @@ -77,8 +75,7 @@ import 'text_viewer.dart'; import 'storage_page.dart'; import 'wifi_share_page.dart'; import '../core/wifi_share.dart' show WifiShare; -import '../core/archive_ops.dart' - show ArchiveEntry, ArchiveKind, ArchivePasswordException, archiveKindOf, extract7zPlan, extractZipPlan, list7z, listZip, planExtract, unsafeArchivePath; +import '../core/archive_ops.dart' show archiveKindOf; import 'batch_rename_dialog.dart'; import '../core/app_trash.dart'; import '../core/folder_size.dart'; @@ -94,6 +91,11 @@ import '../core/explorer/tree_rows.dart'; import '../app/delete_ops.dart'; import '../app/transfer_plan.dart'; import '../core/file_clipboard.dart'; +import '../app/archive_browse.dart'; +import '../app/file_opener.dart'; +import '../core/app_hidden.dart'; +import '../app/rsync_runner.dart'; +import 'explorer/rsync_run_dialog.dart'; export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch; export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText; @@ -970,7 +972,7 @@ class _ExplorerPageState extends State { final arc = _archiveOf(dir); if (arc == null) return null; if (force && !await _readArchive(arc)) return const []; - return _archives.containsKey(arc) ? _archiveList(dir) : null; + return _arc.contains(arc) ? _arc.list(dir) : null; } /// [dir] 의 목록을 정렬. 84: "폴더마다 기억" 이 켜져 있으면 그 폴더의 정렬 (없으면 전체 정렬) @@ -1216,56 +1218,16 @@ class _ExplorerPageState extends State { favorites: _rootsInTree ? [for (final b in c.settings.folderBookmarks) b.path] : const [], isHidden: _appHidden, showHidden: _showAppHidden, - browsable: _archives.containsKey); + browsable: _arc.contains); - /// X-plore 숨기기: [path] 가 목록에서 숨긴 것인지 (앱 설정의 목록 - 파일은 그대로) - bool _appHidden(String path) { - final list = c.settings.explorerHiddenItems; - if (list.isEmpty) return false; - if (!identical(list, _hiddenFrom)) { - _hiddenFrom = list; - _hiddenKeys = {for (final x in list) _hideKey(x)}; - } - return _hiddenKeys.contains(_hideKey(path)); - } + /// X-plore 숨기기: [path] 가 목록에서 숨긴 것인지 (앱 설정의 목록 - 파일은 그대로). 규칙은 HiddenList (E5c) + final _hidden = HiddenList(); + bool _appHidden(String path) => _hidden.isHidden(c.settings.explorerHiddenItems, path); - List? _hiddenFrom; - Set _hiddenKeys = const {}; - - /// 숨긴 목록에서 같은 경로를 찾는 열쇠 (Windows 는 대소문자 무시) - static String _hideKey(String x) { - if (isDav(x) || x.contains('://')) return x; - final n = p.normalize(x); - return Platform.isWindows ? n.toLowerCase() : n; - } - - /// X-plore 숨기기: 옮기거나 이름을 바꾼 것 ([moved] 원래 → 새 경로, 그 안의 것도) 은 숨긴 목록도 새 경로로, - /// 지운 것 ([gone], 그 안의 것도 - 실제로 없어진 것만) 은 목록에서 뺀다 (관리자 결정 10/18) + /// X-plore 숨기기: 옮기거나 이름을 바꾼 것은 숨긴 목록도 새 경로로, 지운 것은 목록에서 뺀다 (관리자 결정 10/18) Future _followHidden({Iterable<(String, String)> moved = const [], Iterable gone = const []}) async { - final list = c.settings.explorerHiddenItems; - if (list.isEmpty || (moved.isEmpty && gone.isEmpty)) return; - final out = []; - var changed = false; - for (final h in list) { - if (gone.any((g) => isSameOrInside(h, g)) && !await vExists(h)) { - changed = true; - continue; - } - var now = h; - for (final (from, to) in moved) { - if (samePath(h, from)) { - now = to; - break; - } - if (isSameOrInside(h, from)) { - now = vJoin(to, isDav(h) ? h.substring(from.length).replaceAll('\\', '/').replaceFirst(RegExp('^/+'), '') : p.relative(h, from: from)); - break; - } - } - if (now != h) changed = true; - if (!out.any((x) => _hideKey(x) == _hideKey(now))) out.add(now); - } - if (changed) await c.updateSettings((s) => s.explorerHiddenItems = out); + final out = await HiddenList.follow(c.settings.explorerHiddenItems, moved: moved, gone: gone); + if (out != null) await c.updateSettings((s) => s.explorerHiddenItems = out); } /// X-plore 숨기기: 목록 맨 아래의 [숨긴 항목 n개 보이기] · [다시 숨기기] 를 누르면 (이번 실행 동안, 모든 창에) @@ -1631,29 +1593,29 @@ class _ExplorerPageState extends State { if (local == null || !mounted) return; path = local; } - if (isVideoFile(path)) { - await _play([path]); - return; - } - // 86: 7z · RAR 은 열면 압축 풀기 (ZIP 은 아래의 보기 · ZIP 화면) - final kind = archiveKindOf(path); - if (kind == ArchiveKind.sevenZip || kind == ArchiveKind.rar) return _extractArchive(path, pane ?? _pane); - // 이미지 · PDF · ZIP 보기 (컴포넌트를 켰을 때) - if (c.settings.components.contains('viewer')) { - final ext = extOf(path); - if (c.settings.imageExts.contains(ext)) return _viewImages(path, pane); - if (ext == 'pdf') return _viewPdf(path); - if (zipExtensions.contains(ext)) return _openZip(path, pane); - } - // 관리자 지시 (10/20): Android 의 HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다 (rhwp - APK 에 들어 있음, 보기 컴포넌트를 켰을 때). - // 열지 못하면 (암호 · 깨진 파일 · 라이브러리 없음) 이유를 알리고 예전처럼 다른 앱으로 - if (Platform.isAndroid && c.settings.components.contains('viewer') && hwpExtensions.contains(extOf(path)) && RhwpEngine.available) { - if (await _viewHwp(path)) return; - } - // 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄) - if (c.settings.components.contains('docs') && Platform.isWindows && - AppComponent.docExtensions.contains(extOf(path))) { - return _viewDoc(path); + // 여는 길은 file_opener (E5c) 가 고른다 + switch (openRouteFor(path, + components: c.settings.components, + imageExts: c.settings.imageExts, + android: Platform.isAndroid, + windows: Platform.isWindows, + hwpEngine: RhwpEngine.available)) { + case OpenRoute.player: + return _play([path]); + case OpenRoute.extractArchive: + return _extractArchive(path, pane ?? _pane); + case OpenRoute.images: + return _viewImages(path, pane); + case OpenRoute.pdf: + return _viewPdf(path); + case OpenRoute.zip: + return _openZip(path, pane); + // 관리자 지시 (10/20): Android HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다. 못 열면 이유를 알리고 다른 앱으로 + case OpenRoute.hwp: + if (await _viewHwp(path)) return; + case OpenRoute.docPreview: + return _viewDoc(path); + case OpenRoute.external: } final local = await _fetch(path); if (local == null) return; @@ -2132,10 +2094,10 @@ class _ExplorerPageState extends State { ? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length]) : trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length])); case 'appUnhide': - final keys = {for (final x in targets) _hideKey(x)}; + final keys = {for (final x in targets) HiddenList.keyOf(x)}; await c.updateSettings((s) => s.explorerHiddenItems = [ for (final x in s.explorerHiddenItems) - if (!keys.contains(_hideKey(x))) x, + if (!keys.contains(HiddenList.keyOf(x))) x, ]); for (final q in _panes) { q.changed(); @@ -2231,136 +2193,47 @@ class _ExplorerPageState extends State { // ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ───────── - /// 펼쳐 본 압축의 목록 (압축 경로 → 항목) - final _archives = >{}; - - /// 압축을 읽은 때의 (크기, 바뀐 때) - 바뀌었으면 다음에 펼치거나 새로 고칠 때 다시 읽는다 (관리자 지시 10/19) - final _archiveStamps = {}; - - static Future<(int, DateTime)?> _stampOf(String path) async { - try { - final st = await File(path).stat(); - if (st.type == FileSystemEntityType.notFound) return null; - return (st.size, st.modified); - } catch (_) { - return null; - } - } - - /// [arc] 의 읽어 둔 목록과 창들의 그 안 목록을 잊는다 (다시 읽게) - void _forgetArchive(String arc) { - _archives.remove(arc); - _archiveStamps.remove(arc); + /// 압축 보기 (읽기 · 바뀌면 다시 읽기 · 안의 목록 · 하나 꺼내기) 는 ArchiveBrowser (E5c). 잊으면 창들의 그 안 목록도 지운다 + late final _arc = ArchiveBrowser(bundledSevenZip, onForget: (arc) { for (final q in _panes) { q.cache.removeWhere((k, _) => samePath(k, arc) || isSameOrInside(k, arc)); } - } + }); /// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면) - bool _canBrowseArchive(FileEntry e) => !widget.rsync && !e.isDir && !isDav(e.path) && archiveKindOf(e.path) != null; - - /// [path] 가 들어 있는 (펼쳐 둔) 압축 - 압축 자신도 - String? _archiveOf(String path) { - String? best; - for (final a in _archives.keys) { - if ((samePath(path, a) || isSameOrInside(path, a)) && (best == null || a.length > best.length)) best = a; - } - return best; - } - - /// 압축 안의 항목인지 (압축 파일 자신은 아님) - bool _insideArchive(String path) { - final a = _archiveOf(path); - return a != null && !samePath(a, path); - } - - /// 압축 안 경로 → 압축 안의 이름 ("폴더/파일") - String _innerName(String archive, String path) => p.relative(path, from: archive).replaceAll('\\', '/'); + bool _canBrowseArchive(FileEntry e) => !widget.rsync && ArchiveBrowser.canBrowse(e); + String? _archiveOf(String path) => _arc.archiveOf(path); + bool _insideArchive(String path) => _arc.inside(path); /// 압축의 목록을 읽어 둔다 (비밀번호가 걸린 7z 등은 [압축 풀기] 로 안내). 읽었으면 true Future _readArchive(String path) async { - final stamp = await _stampOf(path); - if (_archives.containsKey(path)) { - // 펼친 뒤 압축 파일이 바뀌었으면 (크기 · 바뀐 때) 다시 읽는다. 없어졌으면 잊는다 - if (stamp != null && stamp == _archiveStamps[path]) return true; - _forgetArchive(path); - if (stamp == null) return false; - } - final kind = archiveKindOf(path)!; - try { - if (kind == ArchiveKind.zip) { - _archives[path] = await listZip(path); - } else { - final exe = await bundledSevenZip(); - if (exe == null) { - _snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)')); - return false; - } - _archives[path] = await list7z(exe, path); - } - if (stamp != null) _archiveStamps[path] = stamp; - return true; - } on ArchivePasswordException { - _snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요')); - } catch (e) { - _snack(trf('압축을 읽지 못했습니다: {0}', [e])); + switch (await _arc.read(path)) { + case ArchiveRead.ok: + return true; + case ArchiveRead.gone: + return false; + case ArchiveRead.noTool: + _snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)')); + case ArchiveRead.password: + _snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요')); + case ArchiveRead.failed: + _snack(trf('압축을 읽지 못했습니다: {0}', [_arc.lastError])); } return false; } - /// 압축 안 폴더 [dir] 의 바로 아래 항목 (목록의 폴더 이름이 없어도 경로에서 폴더를 만든다) - List _archiveList(String dir) { - final arc = _archiveOf(dir)!; - final rel = samePath(dir, arc) ? '' : _innerName(arc, dir); - final out = {}; - for (final a in _archives[arc]!) { - if (a.isLink) continue; - final n = a.name.replaceAll('\\', '/').replaceAll(RegExp(r'^(\./)+|/+$'), ''); - if (n.isEmpty || unsafeArchivePath(n) != null) continue; - if (rel.isNotEmpty && !n.startsWith('$rel/')) continue; - final rest = rel.isEmpty ? n : n.substring(rel.length + 1); - if (rest.isEmpty) continue; - final first = rest.split('/').first; - final folder = rest.contains('/') || a.isDir; - final path = p.joinAll([arc, ...(rel.isEmpty ? first : '$rel/$first').split('/')]); - final old = out[first]; - if (old == null || (folder && !old.isDir)) { - out[first] = FileEntry(path, isDir: folder, size: folder ? 0 : a.size, modified: a.modified ?? DateTime(0)); - } - } - return out.values.toList(); - } - /// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null) Future _extractOne(String path) async { - final arc = _archiveOf(path)!; - final inner = _innerName(arc, path); - final dest = p.join(await c.services.storage.tempDirectory(), 'jj_arcview', '${arc.hashCode.toUnsigned(32)}'); - final plan = planExtract(_archives[arc]!, names: [inner]); - if (plan.encrypted) { - _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요')); - return null; - } - try { - if (archiveKindOf(arc) == ArchiveKind.zip) { - await extractZipPlan(arc, dest, plan, conflict: NameConflict.overwrite); - } else { - final exe = await bundledSevenZip(); - if (exe == null) return null; - await extract7zPlan(exe, arc, dest, plan, conflict: NameConflict.overwrite); - } - } catch (e) { - _snack(trf('압축에서 꺼내지 못했습니다: {0}', [e])); - return null; - } - final out = p.joinAll([dest, ...inner.split('/')]); - return File(out).existsSync() ? out : null; + final (out, why) = await _arc.extractOne(path, await c.services.storage.tempDirectory()); + if (why == ArchiveRead.password) _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요')); + if (why == ArchiveRead.failed) _snack(trf('압축에서 꺼내지 못했습니다: {0}', [_arc.lastError])); + return out; } /// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로 Future _extractFromArchive(List paths, String? otherDir) async { final arc = _archiveOf(paths.firstWhere(_insideArchive))!; - final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _innerName(arc, x)]; + final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _arc.innerName(arc, x)]; final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names); if (done != null && mounted) await _refreshAll([done]); } @@ -2383,7 +2256,7 @@ class _ExplorerPageState extends State { : tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')); return true; } - if (!paths.any((x) => _insideArchive(x) || (self && _archives.containsKey(x)))) return false; + if (!paths.any((x) => _insideArchive(x) || (self && _arc.contains(x)))) return false; _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); return true; } @@ -3333,272 +3206,54 @@ class _ExplorerPageState extends State { Future _runRsyncInner({required bool right, required bool left}) async { final l = _panes[0].sel.marked.firstOrNull, r = _panes[1].sel.marked.firstOrNull; - if (l == null || r == null) { - _snack(tr('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.')); - return; - } - final runs = [if (right) (l, r), if (left) (r, l)]; - // 34: 대상이 원본과 같거나 원본 안이면 막는다 (맞출 때마다 원본이 바뀜). 원본이 대상 안 (안쪽 → 바깥) 은 지우기가 없을 때만 - if (runs.any((x) => isSameOrInside(x.$2, x.$1))) { - _snack(tr('대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.')); - return; - } - final nested = runs.any((x) => isSameOrInside(x.$1, x.$2)); - if (nested && !c.settings.allowInnerToOuter) { - _snack(tr('원본이 대상 안에 있습니다 (환경 설정에서 막아 둠). 다른 폴더를 고르세요.')); - return; - } - // 23: 다른 쌍은 함께 돌릴 수 있다. 같은 폴더끼리 도는 중이면 (서로 덮어쓰지 않게) 막는다 - if (_jobs.any((j) => - !j.finished && - runs.any((x) => - (samePath(j.sources.first, x.$1) && samePath(j.dest, x.$2)) || - (samePath(j.sources.first, x.$2) && samePath(j.dest, x.$1))))) { - _snack(tr('이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.')); - return; - } - for (final (src, dst) in runs) { - final problem = transferProblem([src], dst, move: false); - if (problem != null) { - _snack(problem); + // 막는 조건 · 작업 · 옵션 · 미리 보기 · 실행은 RsyncRun (E5c), 확인 창은 rsync_run_dialog + final (:run, :problem, :missing) = RsyncRun.prepare(CopyCenter.of(c), + l: l, + r: r, + right: right, + left: left, + allowInnerToOuter: c.settings.allowInnerToOuter, + jobs: _jobs, + viaDav: l != null && r != null && _viaDav([l, r]), + copySkipNewer: c.settings.copySkipNewer); + if (run == null) { + _snack(problem!); + if (missing) { for (final x in _panes) { x.sel.removeWhere(vMissingSync); } - await _refreshAll([vDirname(l), vDirname(r)]); - return; + await _refreshAll([vDirname(l!), vDirname(r!)]); } - } - final both = right && left; - final center = CopyCenter.of(c); - // 확인 창에는 기억한 옵션 (없으면 지금 설정) 을 보여 주고, 실행할 때 모니터링에 기억한다 - // 96: 확인 창 · 미리 보기 · 실행이 같은 작업 (원본 파일 지우기 = 이동 작업) 과 같은 옵션을 쓴다 - List shownTasks(bool move) => - [for (final (src, dst) in runs) center.peek([src], dst, contents: true, method: 'rsync', move: move)]; - // 178: 환경 설정 (또는 기억한) 옵션에 원본 지우기가 있으면 [원본 파일 지우기] 를 켠 채로 연다 (버튼은 [이동] · 경고). - // 원본 지우기는 이 체크로만 정한다 - 옵션 글에서는 빼고 보이고, 끄면 이번 실행에서 정말 빠진다 - final optionMove = runs.map((x) => center.peek([x.$1], x.$2, contents: true, method: 'rsync', move: false)).any((t) => optionsRemoveSource(t.options)); - var tasks = shownTasks(optionMove && !both); - String opts(CopyTask t) { - final o = withoutSourceRemovingOptions(t.options); - return both ? withUpdateOption(o) : o; - } - // 95: ⇄ 는 → 다음에 ← (차례로). ← 에서는 지우지 않는다 (→ 가 방금 복사한 것을 지우지 않게) - String runOpts(int i) => both && i > 0 ? withoutDeleteOptions(opts(tasks[i])) : opts(tasks[i]); - if (nested && tasks.any((t) => optionsDelete(opts(t)))) { - _snack(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).')); return; } - // 한 방향 (→ · ←) 만: 원본 파일 지우기 (--remove-source-files) 와 끝난 뒤 원본 정리 - var removeSource = optionMove && !both; - var prune = 'keep'; - // 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에) - List? preview; - Object? previewError; - // 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때) - var filterAck = false; - // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다 - List filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList(); - // 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 [실행] 을 막는다 - List badOptions() { - final seen = {}; - return [for (final t in tasks) ...optionProblems(opts(t)).where((x) => seen.add(x.text))]; - } - // 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만 - bool deletes() => tasks.any((t) => optionsDelete(opts(t))); - // 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다 - bool nestedDelete() => nested && deletes(); if (!mounted) return; - final go = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog( - scrollable: true, - // 110: 버튼이 좁아 세로로 쌓여도 [취소] 가 먼저 (아래 · 위가 바뀌어 [취소] 자리에 [실행] 이 오지 않게) - actionsOverflowDirection: VerticalDirection.down, - title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'), - content: SizedBox( - width: 560, - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final t in tasks) ...[ - // 109: 끝 폴더가 보이게 짧게, 전체 경로는 작게 - Text(shortPath(t.sources.first), style: const TextStyle(fontWeight: FontWeight.w600)), - Text('→ ${shortPath(t.dest)}', style: const TextStyle(fontWeight: FontWeight.w600)), - Text('${vDisplay(t.sources.first)}/ → ${vDisplay(t.dest)}/', - style: TextStyle(fontSize: 11, color: JjColors.textDim)), - // 111: 이 쌍은 처음 실행 때 기억한 옵션을 쓴다 (환경 설정의 rsync 옵션을 바꿔도 그대로) - if (center.tasks.any((x) => x.id == t.id)) - Text(tr('이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)'), - style: TextStyle(fontSize: 11, color: JjColors.textDim)), - Text('rsync ${runOpts(tasks.indexOf(t))}${removeSource ? ' --remove-source-files' : ''}' - '${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}', - style: TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), - const SizedBox(height: 10), - ], - Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - // 222: 이동이면 동작대로 (예전에는 이동일 때도 "복사 · 원본 폴더는 그대로" 라고 썼다) - if (nested) - Text( - removeSource && !both - ? tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래로 옮겨집니다 (원본 파일은 지워지고, 원본 폴더는 아래에서 고른 대로).') - : tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (원본 폴더는 그대로 남음).'), - style: TextStyle(fontSize: 12, color: JjColors.warning)), - // 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178) - if (both) ...[ - Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - if (optionMove) - Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'), - style: TextStyle(fontSize: 12, color: JjColors.warning)), - ] else ...[ - CheckboxListTile( - contentPadding: EdgeInsets.zero, - value: removeSource, - onChanged: (v) => set(() { - removeSource = v ?? false; - tasks = shownTasks(removeSource); - preview = null; - previewError = null; - }), - title: Text(keepOptionsTogether(tr('원본 파일 지우기 (--remove-source-files)'))), - subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')), - ), - if (removeSource) ...[ - // 178: 원본이 지워진다는 것을 분명히 (환경 설정 옵션 때문에 켜졌으면 그렇다고) - Text( - optionMove - ? tr('원본 파일이 지워집니다 (이동). 환경 설정의 rsync 옵션에 --remove-source-files 가 있어 켜 두었습니다 - 끄면 이번에는 원본을 남깁니다.') - : tr('원본 파일이 지워집니다 (이동).'), - style: TextStyle(fontSize: 12, color: JjColors.warning, fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - const SizedBox(height: 6), - PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)), - ], - ], - // 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐) - const SizedBox(height: 8), - if (badOptions().isNotEmpty) OptionProblemNote(badOptions()), - // 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김) - if (badOptions().isEmpty && filterLimits().isNotEmpty) - FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)), - SyncPreviewView( - // 옵션이 바뀌면 (원본 파일 지우기) 다시 비교 - key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')), - // 177: rsync 로 맞추면 옵션의 거르기 (--exclude 등) 도 비교에 (WebDAV 는 앱이 직접 - 거르기를 따르지 않음) - compute: () async => both - ? previewBoth(l, r, delete: deletes(), filter: RsyncFilter.parse(opts(tasks.first))) - : [ - for (final t in tasks) - ...await previewSync(t.sources.first, t.dest, - toRight: t.sources.first == l, - delete: deletes(), - // 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다 - update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer), - skipLinks: _viaDav([l, r]), - filter: RsyncFilter.parse(opts(t))), - ], - // 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 - onStart: () => set(() { - preview = null; - previewError = null; - }), - onResult: (items) => set(() { - preview = items; - previewError = null; - }), - onError: (e) => set(() { - preview = null; - previewError = e; - }), - ), - if (nestedDelete()) - Text(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).'), - style: TextStyle(fontSize: 12, color: JjColors.alert, fontWeight: FontWeight.w600)), - // 110: 이 줄은 늘 같은 자리를 차지한다 (글이 사라져 창 높이가 바뀌면 버튼이 움직인다) - SizedBox( - height: 34, - child: deletes() && preview == null - ? Text( - previewError != null - ? tr('지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.') - : tr('지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.'), - style: TextStyle(fontSize: 12, color: JjColors.warning), - ) - : null, - ), - const SizedBox(height: 4), - if (_viaDav([l, r])) - Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).'), - style: TextStyle(fontSize: 12, color: JjColors.accent)), - ]), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - Builder(builder: (_) { - final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0; - return FilledButton( - style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, - onPressed: nestedDelete() || badOptions().isNotEmpty || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck) - ? null - : () => Navigator.pop(ctx, true), - // 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨). - // 지울 수는 위의 빨간 글 "받는 쪽에서 N개가 지워집니다" 로 보인다 - child: Text(removeSource ? tr('이동') : tr('실행')), - ); - }), - ], - )), - ); + final go = await showRsyncRunDialog(context, run); // 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘) c.note(trf('Rsync 확인 창 ({0}): {1}', [ - both ? '⇄' : right ? '→' : '←', + run.both ? '⇄' : right ? '→' : '←', go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'), ])); if (go != true || !mounted) return; - if (nestedDelete()) { - _snack(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).')); + if (run.nestedDelete()) { + _snack(RsyncRun.nestedDeleteMessage); return; } - final dav = _viaDav([l, r]); - final exe = dav ? '' : await _ensureRsync(); + final exe = run.viaDav ? '' : await _ensureRsync(); if (exe == null || !mounted) return; - // 96: 확인 창에 보인 그 옵션으로 (기억한 작업의 옵션이 달라도) - final shownOpts = [for (var i = 0; i < tasks.length; i++) runOpts(i)]; - var run = [ - for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource), - ]; - if (removeSource) { - run = [for (final t in run) t.copyWith(prune: prune)]; - for (final t in run) { - await center.update(t); - } - } - // 95: 차례로 (→ 가 끝난 뒤 ←). 앞의 것이 실패 · 취소되면 뒤의 것은 하지 않는다 - final jobs = []; - for (var i = 0; i < run.length; i++) { - c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('Rsync 화면 확인 창의 [실행]'), run[i].sources.first, run[i].dest])); - // 218: Rsync 화면은 rsync 로 (기억한 작업의 방법을 모니터링에서 바꿨어도 - 확인 창에 보인 그대로. WebDAV 가 끼면 앱이 직접) - final j = await center.start(run[i].copyWith(options: shownOpts[i], method: 'rsync'), rsyncExe: dav ? null : exe); - if (!mounted) break; - if (j == null) { - // 205: 시작하지 못하면 이유를 알린다 (예전에는 알림 없이 끝나 "이동이 안 됨" 으로만 보였다) - final why = transferProblem(run[i].sources, run[i].dest, move: run[i].move, contents: run[i].contents); - _snack(trf('시작하지 못했습니다: {0}', [why ?? tr('rsync 를 실행할 수 없습니다')])); - c.note(trf('rsync 시작하지 못함: {0}', [why ?? ''])); - break; - } - jobs.add(j); - setState(() => _jobs = [..._jobs.where((x) => !x.finished), j]); - await j.done; - if (j.error != null) break; - } + final res = await run.execute( + rsyncExe: exe, + alive: () => mounted, + note: c.note, + onStarted: (j) => setState(() => _jobs = [..._jobs.where((x) => !x.finished), j])); + // 205: 시작하지 못하면 이유를 알린다 + if (res.notStarted != null && mounted) _snack(trf('시작하지 못했습니다: {0}', [res.notStarted])); + final jobs = res.jobs; if (jobs.isEmpty || !mounted) return; // 원본 폴더까지 지웠으면 고른 표시도 뺀다 for (final x in _panes) { x.sel.removeWhere(vMissingSync); } - await _refreshAll([l, r, vDirname(l), vDirname(r)]); + await _refreshAll([run.l, run.r, vDirname(run.l), vDirname(run.r)]); for (final x in _panes) { x.changed(); } @@ -4633,7 +4288,7 @@ class _ExplorerPageState extends State { Future _onDrop(DragLoad load, int to, String dest) async { if (!canDropInto(load, to, dest) || !mounted) return; // X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로 - if (_insideArchive(dest) || _archives.containsKey(dest)) { + if (_insideArchive(dest) || _arc.contains(dest)) { _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); return; } @@ -5443,7 +5098,7 @@ class _ExplorerPageState extends State { final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path); final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path); final marked = _isMarked(pane, e.path); - final open = (e.isDir || _archives.containsKey(e.path)) && !list && pane.expanded.contains(e.path); + final open = (e.isDir || _arc.contains(e.path)) && !list && pane.expanded.contains(e.path); final loading = pane.loading.contains(e.path); final name = row.isUp ? '..' diff --git a/test/explorer_modules_e5c_contract_test.dart b/test/explorer_modules_e5c_contract_test.dart new file mode 100644 index 0000000..c3cf141 --- /dev/null +++ b/test/explorer_modules_e5c_contract_test.dart @@ -0,0 +1,121 @@ +import 'dart:io'; + +import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/archive_browse.dart'; +import 'package:jj_mkvmaker/app/copy_center.dart'; +import 'package:jj_mkvmaker/app/file_opener.dart'; +import 'package:jj_mkvmaker/app/rsync_runner.dart'; +import 'package:jj_mkvmaker/core/app_hidden.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry; +import 'package:jj_mkvmaker/core/sync_preview.dart' show PreviewAction; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +/// E5c 약속 시험: lib/app/archive_browse.dart · file_opener.dart · rsync_runner.dart · lib/core/app_hidden.dart 맨 위 약속. +/// 시험 폴더의 실제 파일 · 실제 ZIP (7z 는 동봉 7-Zip 이 있을 때), 설정은 메모리에만. +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_e5c_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + String zipOf(Map files) { + final z = p.join(tmp.path, 'pack.zip'); + final a = Archive(); + files.forEach((k, v) => a.addFile(ArchiveFile.string(k, v))); + File(z).writeAsBytesSync(ZipEncoder().encode(a)); + return z; + } + + test('압축: 읽기 · 안의 목록 (폴더를 경로에서 만듦 · 위험한 이름 뺌) · 바뀌면 다시 읽고 잊음 알림 · 없어짐 · 하나 꺼내기', () async { + final z = zipOf({'2026/beach.jpg': 'jpg', 'readme.txt': 'hello', '../evil.txt': 'x'}); + final forgot = []; + final b = ArchiveBrowser(() async => null, onForget: forgot.add); + expect(ArchiveBrowser.canBrowse(FileEntry(z, isDir: false, modified: DateTime(0))), isTrue); + expect(await b.read(z), ArchiveRead.ok); + expect([for (final e in b.list(z)) '${p.basename(e.path)}${e.isDir ? '/' : ''}']..sort(), ['2026/', 'readme.txt']); + expect(b.list(p.join(z, '2026')).single.path, p.join(z, '2026', 'beach.jpg')); + expect([b.inside(p.join(z, 'readme.txt')), b.inside(z)], [true, false]); + expect(b.innerName(z, p.join(z, '2026', 'beach.jpg')), '2026/beach.jpg'); + final (out, why) = await b.extractOne(p.join(z, 'readme.txt'), tmp.path); + expect(why, ArchiveRead.ok); + expect(File(out!).readAsStringSync(), 'hello'); + // 바뀌면 다시 읽음 (잊음 알림) + await Future.delayed(const Duration(milliseconds: 1100)); + zipOf({'new.txt': 'n'}); + expect(await b.read(z), ArchiveRead.ok); + expect(forgot, [z]); + expect([for (final e in b.list(z)) p.basename(e.path)], ['new.txt']); + File(z).deleteSync(); + expect(await b.read(z), ArchiveRead.gone); + expect(b.contains(z), isFalse); + }); + + test('압축: 7z · RAR 은 7-Zip 이 없으면 noTool', () async { + final s = p.join(tmp.path, 'a.7z'); + File(s).writeAsStringSync('not really'); + expect(await ArchiveBrowser(() async => null).read(s), ArchiveRead.noTool); + }); + + test('여는 길: 동영상 · 7z · 그림 · PDF · ZIP · HWP · 문서 · 바깥 앱 (켠 컴포넌트 · 기기에 따라)', () { + OpenRoute r(String f, {List comps = const ['viewer', 'docs'], bool android = false, bool windows = true, bool hwp = true}) => + openRouteFor(f, components: comps, imageExts: const ['jpg', 'png'], android: android, windows: windows, hwpEngine: hwp); + expect(r('a.mkv'), OpenRoute.player); + expect(r('a.7z'), OpenRoute.extractArchive); + expect(r('a.jpg'), OpenRoute.images); + expect(r('a.jpg', comps: const []), OpenRoute.external, reason: '보기 컴포넌트가 꺼져 있음'); + expect(r('a.pdf'), OpenRoute.pdf); + expect(r('a.cbz'), OpenRoute.zip); + expect(r('a.hwp', android: true, windows: false), OpenRoute.hwp); + expect(r('a.hwp', android: true, windows: false, hwp: false), OpenRoute.external); + expect(r('a.docx'), OpenRoute.docPreview); + expect(r('a.docx', windows: false), OpenRoute.external); + expect(r('a.txt'), OpenRoute.external); + }); + + test('목록에서 숨기기: 같은 경로 (대소문자) · 옮기면 새 경로 · 실제로 없어진 것만 뺌 · 바뀐 것 없으면 null', () async { + final d = p.join(tmp.path, 'Show'); + Directory(p.join(d, 'sub')).createSync(recursive: true); + final keep = p.join(tmp.path, 'keep.txt'); + File(keep).writeAsStringSync('k'); + final list = [p.join(d, 'sub'), keep, p.join(tmp.path, 'gone.txt')]; + final h = HiddenList(); + expect(h.isHidden(list, Platform.isWindows ? keep.toUpperCase() : keep), isTrue); + expect(h.isHidden(list, p.join(tmp.path, 'x')), isFalse); + final moved = await HiddenList.follow(list, moved: [(d, p.join(tmp.path, 'Season'))], gone: [tmp.path]); + expect(moved, [p.join(tmp.path, 'Season', 'sub'), keep], reason: 'gone.txt 은 없음 → 뺌, keep.txt 은 있음 → 남김'); + expect(await HiddenList.follow(list, moved: [(p.join(tmp.path, 'zzz'), p.join(tmp.path, 'yyy'))]), isNull); + }); + + test('Rsync 실행: 막는 이유 · 쌍 · ⇄ 의 옵션 (← 는 지우지 않음 · -u) · 원본 지우기 · 미리 보기 (실제 폴더)', () async { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.rsyncOptions = '-a --delete'; + final center = CopyCenter.of(c); + final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R'); + Directory(l).createSync(); + Directory(r).createSync(); + File(p.join(l, 'a.txt')).writeAsStringSync('a'); + File(p.join(r, 'old.txt')).writeAsStringSync('o'); + ({RsyncRun? run, String? problem, bool missing}) prep(String? a, String? b, {bool right = true, bool left = false, bool inner = true}) => + RsyncRun.prepare(center, l: a, r: b, right: right, left: left, allowInnerToOuter: inner, jobs: const [], viaDav: false, copySkipNewer: false); + expect(prep(null, r).problem, isNotNull); + expect(prep(l, p.join(l, 'in')).problem, isNotNull, reason: '대상이 원본 안'); + Directory(p.join(r, 'sub')).createSync(); + expect(prep(p.join(r, 'sub'), r, inner: false).problem, isNotNull, reason: '원본이 대상 안 (설정이 막음)'); + expect(prep(p.join(r, 'sub'), r).problem, RsyncRun.nestedDeleteMessage, reason: '원본이 대상 안 + --delete'); + final gone = prep(p.join(tmp.path, 'none'), r); + expect([gone.problem != null, gone.missing], [true, true]); + final one = prep(l, r).run!; + expect([one.runs.length, one.both, one.deletes()], [1, false, true]); + final items = await one.preview(); + expect(items.any((x) => x.action == PreviewAction.delete), isTrue, reason: '실제 폴더 비교: 받는 쪽에만 있는 것은 지움'); + final two = prep(l, r, left: true).run!; + expect(two.runOpts(0), contains('-u')); + expect(two.runOpts(1), isNot(contains('--delete')), reason: '← 에서는 지우지 않음'); + one.setRemoveSource(true); + expect([one.removeSource, one.tasks.single.move], [true, true]); + }); +}