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
parent
0300d51092
commit
067746223b
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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('실행')),
|
||||
);
|
||||
}),
|
||||
],
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
? '..'
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue