E5c: 압축 보기 · 여는 길 · 목록에서 숨기기 · Rsync 실행을 모듈로 + 점검 통합 시험

관리자 승인 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 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 09:51:34 +09:00
parent 0300d51092
commit 067746223b
8 changed files with 999 additions and 438 deletions

View File

@ -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<File>()) {
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<void> settle([int n = 12]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump();
}
}
var no = 0;
Future<void> 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 = <String>{};
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<void> step(String name, Future<void> 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<void> 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<void> 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);
});
}
}

148
lib/app/archive_browse.dart Normal file
View File

@ -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<String?> Function() sevenZip;
/// 읽어 둔 압축을 잊을 때 (바뀜 · 없어짐) - 부르는 쪽이 그 안의 읽어 둔 목록을 지우게
final void Function(String arc)? onForget;
/// 펼쳐 본 압축의 목록 (압축 경로 → 항목)
final _entries = <String, List<ArchiveEntry>>{};
/// 압축을 읽은 때의 (크기, 바뀐 때)
final _stamps = <String, (int, DateTime)>{};
/// [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<ArchiveRead> 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<FileEntry> list(String dir) {
final arc = archiveOf(dir)!;
final rel = samePath(dir, arc) ? '' : innerName(arc, dir);
final out = <String, FileEntry>{};
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);
}
}

35
lib/app/file_opener.dart Normal file
View File

@ -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<String> components,
required List<String> 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;
}

164
lib/app/rsync_runner.dart Normal file
View File

@ -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<CopyTask> tasks;
List<CopyTask> _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<TransferJob> 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<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
/// 관리자 지시 (10/20): 고쳐 읽어도 알아볼 수 없는 옵션 (있으면 [실행] 을 막음)
List<OptionProblem> badOptions() {
final seen = <String>{};
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<List<PreviewItem>> 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<TransferJob> 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 = <TransferJob>[];
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);
}
}

61
lib/core/app_hidden.dart Normal file
View File

@ -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<String>? _from;
Set<String> _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<String> 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<List<String>?> follow(List<String> list,
{Iterable<(String, String)> moved = const [],
Iterable<String> gone = const [],
Future<bool> Function(String path) exists = vExists}) async {
if (list.isEmpty || (moved.isEmpty && gone.isEmpty)) return null;
final out = <String>[];
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;
}
}

View File

@ -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<bool?> showRsyncRunDialog(BuildContext context, RsyncRun run) {
// 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에)
List<PreviewItem>? preview;
Object? previewError;
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때)
var filterAck = false;
return showDialog<bool>(
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('실행')),
);
}),
],
)),
);
}

View File

@ -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<ExplorerPage> {
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<ExplorerPage> {
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<String>? _hiddenFrom;
Set<String> _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<void> _followHidden({Iterable<(String, String)> moved = const [], Iterable<String> gone = const []}) async {
final list = c.settings.explorerHiddenItems;
if (list.isEmpty || (moved.isEmpty && gone.isEmpty)) return;
final out = <String>[];
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<ExplorerPage> {
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<ExplorerPage> {
? 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<ExplorerPage> {
// ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ─────────
/// 펼쳐 본 압축의 목록 (압축 경로 → 항목)
final _archives = <String, List<ArchiveEntry>>{};
/// 압축을 읽은 때의 (크기, 바뀐 때) - 바뀌었으면 다음에 펼치거나 새로 고칠 때 다시 읽는다 (관리자 지시 10/19)
final _archiveStamps = <String, (int, DateTime)>{};
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<bool> _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<FileEntry> _archiveList(String dir) {
final arc = _archiveOf(dir)!;
final rel = samePath(dir, arc) ? '' : _innerName(arc, dir);
final out = <String, FileEntry>{};
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<String?> _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<void> _extractFromArchive(List<String> 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<ExplorerPage> {
: 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<ExplorerPage> {
Future<void> _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<CopyTask> 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<PreviewItem>? preview;
Object? previewError;
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때)
var filterAck = false;
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다
List<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 [실행] 을 막는다
List<OptionProblem> badOptions() {
final seen = <String>{};
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<bool>(
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 = <TransferJob>[];
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<ExplorerPage> {
Future<void> _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<ExplorerPage> {
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
? '..'

View File

@ -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<String, String> 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 = <String>[];
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<void>.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<String> 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]);
});
}