diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 2540861..fcb8249 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -156,6 +156,7 @@ "MKV · AI 작업": "MKV · AI task", "MKV · 자막 (jj_mkv)": "MKV · subtitles (jj_mkv)", "MKV · 자막 저장 위치": "Where to save MKV · subtitles", + "MKV 결과 폴더": "MKV output folder", "MKV 만들기": "Make MKV", "MKV 만들기 (대기열에 추가)": "Make MKV (add to queue)", "MKV 만들기 {0}개": "Make {0} MKVs", @@ -1174,6 +1175,7 @@ "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "Icons · list look: X-plore (thumbnails · two lines) · Windows Explorer (color icons · columns) · Total Commander (dense list · [folders])", "아직 만든 MKV · 자막이 없습니다: {0}": "No MKV · subtitles made yet: {0}", "아직 실행 안 함": "Not run yet", + "아직 없습니다. 아래에서 지금 폴더를 넣으세요.": "None yet. Add the current folder below.", "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "None yet. Videos come in automatically once downloaded (change in Settings).", "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "Not yet (downloaded when you copy with rsync · or press [Download])", "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment", @@ -1456,6 +1458,7 @@ "이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})", "이름": "Name", "이름 (탭에 보임, 비우면 주소)": "Name (shown on the tab; address if empty)", + "이름 바꾸기": "Rename", "이름 바꾸기 (예: 이름 (2))": "Keep both (e.g. name (2))", "이름 바꾸기 · 이동": "Rename · move", "이름 변경": "Rename", @@ -1693,6 +1696,7 @@ "즐겨찾기 추가…": "Add bookmark…", "즐겨찾기 추가됨": "Bookmark added", "즐겨찾기 표시줄": "Bookmarks bar", + "즐겨찾기에서 빼기 (폴더는 그대로)": "Remove from favorites (the folder stays)", "지금": "Current", "지금 {0}%": "Now {0}%", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?", @@ -1708,6 +1712,10 @@ "지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}", "지금 작업 취소: {0}": "Cancelled current job: {0}", "지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)", + "지금 짝 추가": "Add current pair", + "지금 짝을 즐겨찾기에": "Add the current pair to favorites", + "지금 폴더 추가": "Add current folder", + "지금 폴더를 즐겨찾기에": "Add the current folder to favorites", "지금: {0}": "Now: {0}", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 9f49238..0368377 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -156,6 +156,7 @@ "MKV · AI 작업": "MKV・AI 作業", "MKV · 자막 (jj_mkv)": "MKV・字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV・字幕の保存先", + "MKV 결과 폴더": "MKV 出力フォルダー", "MKV 만들기": "MKV 作成", "MKV 만들기 (대기열에 추가)": "MKV 作成(キューに追加)", "MKV 만들기 {0}개": "MKV 作成 {0}個", @@ -1174,6 +1175,7 @@ "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])", "아직 만든 MKV · 자막이 없습니다: {0}": "まだ作成した MKV・字幕がありません: {0}", "아직 실행 안 함": "まだ実行していません", + "아직 없습니다. 아래에서 지금 폴더를 넣으세요.": "まだありません。下で今のフォルダーを追加してください。", "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "まだありません。動画のダウンロードが終わると自動で入ります(設定で変更)。", "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)", "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください", @@ -1456,6 +1458,7 @@ "이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})", "이름": "名前", "이름 (탭에 보임, 비우면 주소)": "名前 (タブに表示、空ならアドレス)", + "이름 바꾸기": "名前を変更", "이름 바꾸기 (예: 이름 (2))": "名前を変える (例: 名前 (2))", "이름 바꾸기 · 이동": "名前の変更・移動", "이름 변경": "名前の変更", @@ -1693,6 +1696,7 @@ "즐겨찾기 추가…": "ブックマークを追加…", "즐겨찾기 추가됨": "ブックマークを追加しました", "즐겨찾기 표시줄": "ブックマークバー", + "즐겨찾기에서 빼기 (폴더는 그대로)": "お気に入りから外す (フォルダーはそのまま)", "지금": "現在", "지금 {0}%": "現在 {0}%", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?", @@ -1708,6 +1712,10 @@ "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", "지금 작업 취소: {0}": "現在の作業をキャンセル: {0}", "지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)", + "지금 짝 추가": "今のペアを追加", + "지금 짝을 즐겨찾기에": "今のペアをお気に入りに", + "지금 폴더 추가": "今のフォルダーを追加", + "지금 폴더를 즐겨찾기에": "今のフォルダーをお気に入りに", "지금: {0}": "現在: {0}", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 2b44b0a..00a03a3 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -156,6 +156,7 @@ "MKV · AI 작업": "MKV · AI 任务", "MKV · 자막 (jj_mkv)": "MKV · 字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV · 字幕保存位置", + "MKV 결과 폴더": "MKV 输出文件夹", "MKV 만들기": "制作 MKV", "MKV 만들기 (대기열에 추가)": "制作 MKV(加入队列)", "MKV 만들기 {0}개": "制作 MKV {0} 个", @@ -1174,6 +1175,7 @@ "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])", "아직 만든 MKV · 자막이 없습니다: {0}": "尚未制作 MKV · 字幕: {0}", "아직 실행 안 함": "尚未运行", + "아직 없습니다. 아래에서 지금 폴더를 넣으세요.": "还没有。请在下方添加当前文件夹。", "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "暂无。视频下载完成后会自动加入(可在设置中更改)。", "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)", "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试", @@ -1456,6 +1458,7 @@ "이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})", "이름": "名称", "이름 (탭에 보임, 비우면 주소)": "名称 (显示在标签上,留空则显示地址)", + "이름 바꾸기": "重命名", "이름 바꾸기 (예: 이름 (2))": "重命名 (例: 名称 (2))", "이름 바꾸기 · 이동": "重命名 · 移动", "이름 변경": "重命名", @@ -1693,6 +1696,7 @@ "즐겨찾기 추가…": "添加书签…", "즐겨찾기 추가됨": "已添加书签", "즐겨찾기 표시줄": "书签栏", + "즐겨찾기에서 빼기 (폴더는 그대로)": "从收藏中移除(文件夹保留)", "지금": "当前", "지금 {0}%": "当前 {0}%", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?", @@ -1708,6 +1712,10 @@ "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", "지금 작업 취소: {0}": "已取消当前任务: {0}", "지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)", + "지금 짝 추가": "添加当前一对", + "지금 짝을 즐겨찾기에": "将当前一对加入收藏", + "지금 폴더 추가": "添加当前文件夹", + "지금 폴더를 즐겨찾기에": "将当前文件夹加入收藏", "지금: {0}": "当前: {0}", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 3e9e1bf..a7f01d8 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -36,6 +36,27 @@ class MoveTarget { int get hashCode => Object.hash(name, dir); } +/// 73: 파일 탐색기 즐겨찾기 (폴더). [right] 가 있으면 Rsync 화면의 "왼쪽 + 오른쪽" 짝 +class FolderBookmark { + final String name; + final String path; + final String? right; + const FolderBookmark({required this.name, required this.path, this.right}); + + bool get isPair => right != null; + + FolderBookmark copyWith({String? name}) => FolderBookmark(name: name ?? this.name, path: path, right: right); + + Map toJson() => {'name': name, 'path': path, if (right != null) 'right': right}; + + static FolderBookmark? fromJson(Object? j) => j is Map && j['path'] is String + ? FolderBookmark( + name: j['name'] is String && (j['name'] as String).trim().isNotEmpty ? j['name'] as String : j['path'] as String, + path: j['path'] as String, + right: j['right'] is String ? j['right'] as String : null) + : null; +} + /// 실시간 동기화 한 쌍 (lsyncd 처럼: 원본 폴더를 지켜보다가 바뀌면 대상 폴더에 맞춘다) class LiveSyncPair { final String source; @@ -211,6 +232,9 @@ class AppSettings { /// 비어 있으면 [이동] 을 처음 누를 때 폴더를 골라 하나 만든다. List moveTargets = []; + /// 73: 파일 탐색기 · Rsync 즐겨찾기 (폴더, Rsync 는 짝) + List folderBookmarks = []; + /// 웹 브라우저에서 YouTube 광고 자동 건너뛰기 (건너뛰기 버튼 누르기 · 건너뛸 수 없는 광고는 빨리 감기 · 소리 끄기) bool youtubeAdSkip = true; @@ -576,6 +600,7 @@ class AppSettings { ...stripSecrets(extra), 'mkvOutputRoot': mkvOutputRoot, 'moveTargets': [for (final t in moveTargets) t.toJson()], + 'folderBookmarks': [for (final b in folderBookmarks) b.toJson()], 'youtubeAdSkip': youtubeAdSkip, 'youtubeAdHide': youtubeAdHide, 'webPauseOnLeave': webPauseOnLeave, @@ -749,6 +774,7 @@ class AppSettings { final e = (j['encode'] as Map?) ?? const {}; final s = AppSettings() ..mkvOutputRoot = j['mkvOutputRoot'] as String? + ..folderBookmarks = [for (final x in (j['folderBookmarks'] as List?) ?? const []) ?FolderBookmark.fromJson(x)] ..moveTargets = [ for (final x in (j['moveTargets'] as List?) ?? const []) if (x is Map && x['dir'] is String) MoveTarget.fromJson(x), diff --git a/lib/ui/explorer_bookmarks.dart b/lib/ui/explorer_bookmarks.dart new file mode 100644 index 0000000..27422b2 --- /dev/null +++ b/lib/ui/explorer_bookmarks.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; + +import '../app/app_controller.dart'; +import '../app/settings.dart'; +import '../core/file_ops.dart' show samePath; +import '../core/vfs.dart'; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// 73: 파일 탐색기 즐겨찾기 - 지금 폴더를 넣고 창 위에서 한 번에 간다. +/// Rsync 화면에서는 "왼쪽 A + 오른쪽 B" 짝을 넣고 한 번에 연다. +/// [right] 가 있으면 Rsync 화면 (지금 오른쪽 폴더). [go] 는 이 창으로, [goPair] 는 두 창을 함께. +Future showFolderBookmarks( + BuildContext context, + AppController c, { + required String current, + String? right, + required void Function(String dir) go, + void Function(String left, String right)? goPair, +}) async { + await showDialog( + context: context, + builder: (ctx) => _BookmarksDialog(c: c, current: current, right: right, go: go, goPair: goPair), + ); +} + +class _BookmarksDialog extends StatefulWidget { + final AppController c; + final String current; + final String? right; + final void Function(String dir) go; + final void Function(String left, String right)? goPair; + const _BookmarksDialog({required this.c, required this.current, this.right, required this.go, this.goPair}); + + @override + State<_BookmarksDialog> createState() => _BookmarksDialogState(); +} + +class _BookmarksDialogState extends State<_BookmarksDialog> { + AppSettings get s => widget.c.settings; + bool get _rsync => widget.right != null && widget.goPair != null; + + Future _save(List list) async { + await widget.c.updateSettings((x) => x.folderBookmarks = list); + if (mounted) setState(() {}); + } + + Future _askName(String initial, String title) async { + final name = await showDialog(context: context, builder: (_) => _NameDialog(initial: initial, title: title)); + final t = name?.trim(); + return t == null || t.isEmpty ? null : t; + } + + Future _add({required bool pair}) async { + final b = pair + ? FolderBookmark(name: '${vBasename(widget.current)} ⇄ ${vBasename(widget.right!)}', path: widget.current, right: widget.right) + : FolderBookmark(name: vBasename(widget.current).isEmpty ? widget.current : vBasename(widget.current), path: widget.current); + final name = await _askName(b.name, pair ? tr('지금 짝을 즐겨찾기에') : tr('지금 폴더를 즐겨찾기에')); + if (name == null) return; + await _save([...s.folderBookmarks, b.copyWith(name: name)]); + } + + void _open(FolderBookmark b) { + Navigator.pop(context); + if (b.isPair && widget.goPair != null) { + widget.goPair!(b.path, b.right!); + } else { + widget.go(b.path); + } + } + + @override + Widget build(BuildContext context) { + final mine = [for (final b in s.folderBookmarks) if (_rsync || !b.isPair) b]; + // 바로 가기: 다운로드 폴더 · MKV 결과 폴더 (넣지 않아도) + final shortcuts = [ + (tr('다운로드'), s.resolvedDownloadRoot()), + if (s.mkvOutputRoot?.isNotEmpty ?? false) (tr('MKV 결과 폴더'), s.mkvOutputRoot!), + ].where((x) => !mine.any((b) => !b.isPair && samePath(b.path, x.$2))).toList(); + final already = mine.any((b) => !b.isPair && samePath(b.path, widget.current)); + final pairAlready = _rsync && mine.any((b) => b.isPair && samePath(b.path, widget.current) && samePath(b.right!, widget.right!)); + return AlertDialog( + title: Text(tr('즐겨찾기')), + contentPadding: const EdgeInsets.fromLTRB(8, 12, 8, 0), + content: SizedBox( + width: 480, + child: ListView(shrinkWrap: true, children: [ + if (mine.isEmpty) + Padding( + padding: const EdgeInsets.all(12), + child: Text(tr('아직 없습니다. 아래에서 지금 폴더를 넣으세요.'), style: const TextStyle(color: JjColors.textDim)), + ), + for (final b in mine) + ListTile( + dense: true, + leading: Icon(b.isPair ? Icons.compare_arrows : Icons.star, color: Colors.amber, size: 20), + title: Text(b.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text(b.isPair ? '${vDisplay(b.path)}\n⇄ ${vDisplay(b.right!)}' : vDisplay(b.path), + maxLines: b.isPair ? 2 : 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + onTap: () => _open(b), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + tooltip: tr('이름 바꾸기'), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () async { + final name = await _askName(b.name, tr('이름 바꾸기')); + if (name != null) await _save([for (final x in s.folderBookmarks) identical(x, b) ? x.copyWith(name: name) : x]); + }, + ), + IconButton( + tooltip: tr('즐겨찾기에서 빼기 (폴더는 그대로)'), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 18), + onPressed: () => _save([for (final x in s.folderBookmarks) if (!identical(x, b)) x]), + ), + ]), + ), + if (shortcuts.isNotEmpty) ...[ + const Divider(), + for (final (name, dir) in shortcuts) + ListTile( + dense: true, + leading: const Icon(Icons.folder_special_outlined, size: 20), + title: Text(name), + subtitle: Text(vDisplay(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + onTap: () { + Navigator.pop(context); + widget.go(dir); + }, + ), + ], + ]), + ), + actions: [ + if (_rsync) + TextButton.icon( + onPressed: pairAlready ? null : () => _add(pair: true), + icon: const Icon(Icons.compare_arrows, size: 18), + label: Text(tr('지금 짝 추가')), + ), + TextButton.icon( + onPressed: already || widget.current.isEmpty ? null : () => _add(pair: false), + icon: const Icon(Icons.star_border, size: 18), + label: Text(tr('지금 폴더 추가')), + ), + TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기'))), + ], + ); + } +} + +/// 표시 이름 넣기 (글 칸은 창이 닫히는 동안에도 그려지므로 창이 스스로 가지고 버린다) +class _NameDialog extends StatefulWidget { + final String initial; + final String title; + const _NameDialog({required this.initial, required this.title}); + + @override + State<_NameDialog> createState() => _NameDialogState(); +} + +class _NameDialogState extends State<_NameDialog> { + late final _ctl = TextEditingController(text: widget.initial); + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _ctl, + autofocus: true, + decoration: InputDecoration(labelText: tr('표시 이름')), + onSubmitted: (v) => Navigator.pop(context, v), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(context, _ctl.text), child: Text(tr('확인'))), + ], + ); +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index ca8fc20..b721509 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -39,6 +39,7 @@ import '../app/components.dart'; import 'zip_page.dart'; import '../core/reader_sources.dart'; import '../l10n/tr.dart'; +import 'explorer_bookmarks.dart'; /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. /// @@ -125,6 +126,8 @@ enum ExplorerButton { sort(Icons.sort, '정렬 기준'), hidden(Icons.visibility_outlined, '숨은 항목 표시'), history(Icons.history, '내역'), + // 73: 즐겨찾기 (지금 폴더 넣기 · 한 번에 가기, Rsync 는 짝) + bookmarks(Icons.star_outline, '즐겨찾기'), // 118: 두 창을 좌우 ⇆ / 위아래 ⇅ 로 한 번에 바꾸기 (한 창이면 두 창 (좌우) 으로) orient(Icons.swap_horiz, '좌우 ⇆ / 위아래 ⇅'), layout(Icons.view_quilt_outlined, '창 배치'), @@ -141,11 +144,11 @@ enum ExplorerButton { static List get explorerValues => [for (final b in values) if (!b.rsyncOnly) b]; /// Rsync 화면의 버튼 (늘 이대로, 두 창 사이) - static const rsyncButtons = [up, refresh, newFolder, toRight, toLeft, both, monitor]; + static const rsyncButtons = [up, refresh, newFolder, bookmarks, toRight, toLeft, both, monitor]; /// 147 (관리자): 처음 값 (버튼 구성을 바꾸지 않았을 때) 은 자주 쓰는 것부터 - 좁은 화면에서 [더 보기] 로 가는 것은 뒤쪽 static List get defaultOrder { - const first = [copy, move, delete, newFolder, rename, select, search, orient]; + const first = [copy, move, delete, newFolder, rename, select, search, bookmarks, orient]; return [...first, for (final b in explorerValues) if (!first.contains(b)) b]; } @@ -1288,6 +1291,8 @@ class _ExplorerPageState extends State { _snack(c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.')); case ExplorerButton.history: await _historyDialog(pane); + case ExplorerButton.bookmarks: + await _bookmarks(pane); case ExplorerButton.orient: await _toggleOrientation(); case ExplorerButton.layout: @@ -2323,6 +2328,25 @@ class _ExplorerPageState extends State { } } + /// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에 + Future _bookmarks(_Pane pane) async { + setState(() => _active = _panes.indexOf(pane)); + await showFolderBookmarks( + context, + c, + current: pane.current, + right: widget.rsync ? _panes[1].current : null, + go: (dir) => _goTo(pane, dir, fresh: true), + goPair: widget.rsync + ? (a, b) { + _goTo(_panes[0], a, fresh: true); + _goTo(_panes[1], b, fresh: true); + } + : null, + ); + if (mounted) setState(() {}); + } + Future _historyDialog(_Pane pane) async { final hist = c.settings.explorerHistory.where(_reachable).toList(); final pick = await showDialog( @@ -2773,6 +2797,16 @@ class _ExplorerPageState extends State { scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), children: [ + // 73: 즐겨찾기 (창 위에서 한 번에 - 드라이브가 많아도 보이게 맨 앞) + IconButton( + tooltip: tr('즐겨찾기'), + visualDensity: VisualDensity.compact, + icon: Icon( + c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, pane.current)) ? Icons.star : Icons.star_outline, + size: 16, + color: Colors.amber), + onPressed: () => _bookmarks(pane), + ), // 37: 지금 열어 둔 네트워크 공유 (\\NAS\공유) 도 탭으로 for (final (path, label) in [ ..._volumes, diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index bbfdda4..03f0b70 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -351,8 +351,8 @@ void main() { expect(c.settings.explorerShowHidden, !before); // 창 배치 버튼은 좁아도 늘 줄에 (사용자 요청) · 처음 값은 자주 쓰는 것부터 (복사 · 이동 · 삭제 …) expect(find.byTooltip('창 배치: 좌우 (누르면 위아래)'), findsOneWidget); - expect(ExplorerButton.fromSettings(const []).take(8).map((b) => b.name), - ['copy', 'move', 'delete', 'newFolder', 'rename', 'select', 'search', 'orient']); + expect(ExplorerButton.fromSettings(const []).take(9).map((b) => b.name), + ['copy', 'move', 'delete', 'newFolder', 'rename', 'select', 'search', 'bookmarks', 'orient']); expect(ExplorerButton.fromSettings(const ['hidden', 'copy']), [ExplorerButton.hidden, ExplorerButton.copy], reason: '사용자가 정한 순서는 그대로'); // 넉넉한 화면에서는 [더 보기] 없이 모두 diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 9b3a2ee..be965d1 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -164,6 +164,14 @@ void main() { await tester.pumpAndSettle(); await act(tester, () => tester.tap(find.text('..'))); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + // 줄에 자리가 모자라면 [더 보기] 안에 있다 (버튼이 늘면 - 73 즐겨찾기) + if (find.text('붙여넣기').evaluate().isEmpty) { + // 아래 버튼 줄을 가리는 "담았습니다" 알림을 먼저 닫는다 + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pumpAndSettle(); + await tester.tap(find.text('더 보기')); + await tester.pumpAndSettle(); + } await act(tester, () => tester.tap(find.text('붙여넣기'))); await tester.pumpAndSettle(); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); @@ -181,6 +189,9 @@ void main() { expect(find.text('doc'), findsOneWidget); // 확장자를 뺀 이름 expect(find.text('txt'), findsNWidgets(2)); // 확장자 칸 (doc.txt · 붙여넣은 inner.txt) expect(find.text('확장자'), findsOneWidget); + // 알림 (복사 끝 등) 이 시험이 끝난 뒤에 닫히지 않게 정리 + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pumpAndSettle(); }); testWidgets('선택 모드: 파일 누르기 = 선택 · 취소, 폴더 누르기 = 자기 자신과 안의 것 모두 선택 · 다시 누르면 모두 취소', (tester) async { @@ -252,6 +263,76 @@ void main() { await tester.pumpAndSettle(); }, skip: !Platform.isWindows); + testWidgets('73: 즐겨찾기 - 지금 폴더 넣기 (이름 고침) · 한 번에 가기 · 빼기 (폴더는 그대로)', (tester) async { + await open(tester); + // 창 위의 ★ (왼쪽 창) + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await tester.pumpAndSettle(); + expect(find.text('아직 없습니다. 아래에서 지금 폴더를 넣으세요.'), findsOneWidget); + await tester.tap(find.text('지금 폴더 추가')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).last, '왼쪽 폴더'); + await tester.tap(find.widgetWithText(FilledButton, '확인')); + await tester.pumpAndSettle(); + expect([for (final b in c.settings.folderBookmarks) (b.name, b.path)], [('왼쪽 폴더', left)]); + expect(find.text('왼쪽 폴더'), findsOneWidget); + await tester.tap(find.text('닫기')); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.star), findsOneWidget, reason: '즐겨찾기한 폴더를 보는 창의 ★ 는 채움'); + + // 오른쪽 폴더를 즐겨찾기에 두고 왼쪽 창에서 한 번에 가기 + await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: 'R', path: right)]); + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await tester.pumpAndSettle(); + await act(tester, () => tester.tap(find.text('R'))); + await settle(tester, () => false, rounds: 20); + expect(find.byIcon(Icons.star), findsNWidgets(2), reason: '두 창 모두 즐겨찾기한 폴더 (왼쪽 창이 R 로 감)'); + + // 빼기: 즐겨찾기에서만 (폴더는 그대로) + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('즐겨찾기에서 빼기 (폴더는 그대로)').first); + await tester.pumpAndSettle(); + expect([for (final b in c.settings.folderBookmarks) b.name], ['R']); + expect(Directory(left).existsSync(), isTrue); + await tester.tap(find.text('닫기')); + await tester.pumpAndSettle(); + }); + + testWidgets('73: Rsync 화면 - "왼쪽 + 오른쪽" 짝을 넣고 한 번에 연다', (tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + c.settings.rsyncPaths = [left, right]; + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await tester.pumpAndSettle(); + await tester.tap(find.text('지금 짝 추가')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '확인')); + await tester.pumpAndSettle(); + final pair = c.settings.folderBookmarks.single; + expect((pair.path, pair.right, pair.name), (left, right, 'left ⇄ right')); + await tester.tap(find.text('닫기')); + await tester.pumpAndSettle(); + // 반대 짝을 넣어 두고 열면 두 창이 함께 바뀐다 + await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: '거꾸로', path: right, right: left)]); + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await tester.pumpAndSettle(); + await act(tester, () => tester.tap(find.text('거꾸로'))); + await settle(tester, () => c.settings.rsyncPaths.length == 2 && c.settings.rsyncPaths[0] == right && c.settings.rsyncPaths[1] == left, + rounds: 100); + expect(c.settings.rsyncPaths, [right, left]); + }); + + test('73: 즐겨찾기 저장 · 읽기 (Rsync 짝 포함)', () { + final s = AppSettings() + ..folderBookmarks = const [FolderBookmark(name: 'NAS/영화', path: r'\\nas\movies'), FolderBookmark(name: '백업', path: r'D:\a', right: r'E:\b')]; + final b = AppSettings.fromJson(s.toJson()).folderBookmarks; + expect([for (final x in b) (x.name, x.path, x.right, x.isPair)], [('NAS/영화', r'\\nas\movies', null, false), ('백업', r'D:\a', r'E:\b', true)]); + }); + testWidgets('여러 개 표시 → 삭제 · 새 폴더 · 이름 변경', (tester) async { await open(tester); await act(tester, () => tester.tap(find.text('선택').first));