diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 462196e..df35790 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -459,6 +459,7 @@ "경로": "Path", "경로 입력": "Enter path", "경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "Enter path (e.g. D:\\Videos · \\\\NAS\\share)", + "경로 입력 · WebDAV": "Enter path · WebDAV", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "The path is too long for the Recycle Bin (the Windows Recycle Bin does not accept paths over 260 characters).", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently", "계속": "Always", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 0016015..fb0382a 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -459,6 +459,7 @@ "경로": "パス", "경로 입력": "パスを入力", "경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "パスを入力 (例: D:\\動画・\\\\NAS\\共有)", + "경로 입력 · WebDAV": "パス入力 · WebDAV", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます", "계속": "常時", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index e338c43..705bc34 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -459,6 +459,7 @@ "경로": "路径", "경로 입력": "输入路径", "경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "输入路径 (例: D:\\视频 · \\\\NAS\\共享)", + "경로 입력 · WebDAV": "输入路径 · WebDAV", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除", "계속": "持续", diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 17e6d8f..8a15e63 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -807,6 +807,8 @@ Stream searchFiles(String root, String query, /// "1.2GB" · "350MB" · "12KB" String formatSize(int bytes) { + // 203: 크기를 모름 (잠긴 시스템 파일 · 읽지 못함 → -1) 은 "-1B" 가 아니라 "—" + if (bytes < 0) return '—'; const k = 1024; if (bytes >= k * k * k) return '${(bytes / (k * k * k)).toStringAsFixed(1)}GB'; if (bytes >= k * k) return '${(bytes / (k * k)).toStringAsFixed(bytes < 10 * k * k ? 1 : 0)}MB'; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 0e8bd67..c049154 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -5,7 +5,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform; -import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton; +import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, PointerDeviceKind, PointerScrollEvent; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; @@ -210,6 +210,9 @@ class _Pane extends ChangeNotifier { /// "폴더 + 파일 목록" 배치의 오른쪽 파일 목록 스크롤 final listScroll = ScrollController(); + /// 202: 창 위 저장 장치 탭 줄 (넘치면 옆으로 - 휠 · 끌기) + final tabScroll = ScrollController(); + _Pane(this.root) : _current = root; /// 화면을 닫은 뒤 끝난 파일 작업 · 읽기가 알리지 않게 @@ -224,6 +227,7 @@ class _Pane extends ChangeNotifier { _disposed = true; scroll.dispose(); listScroll.dispose(); + tabScroll.dispose(); super.dispose(); } } @@ -3032,12 +3036,45 @@ class _ExplorerPageState extends State { } /// 창 위쪽: 저장 장치 고르기 + /// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력]. + /// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다) + /// 좁은 창 (위아래 두 창 · 폰) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임) Widget _paneHeader(_Pane pane) => SizedBox( height: 40, - child: ListView( - scrollDirection: Axis.horizontal, + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - children: [ + child: LayoutBuilder( + builder: (context, box) => box.maxWidth >= 320 + ? Row(children: [ + ..._headerStart(pane), + Expanded(child: _driveTabs(pane)), + ..._headerEnd(pane), + ]) + : Row(children: [ + Expanded(child: _driveTabs(pane, lead: _headerStart(pane))), + _headerMenu(pane), + ]), + ), + ), + ); + + /// 202: 좁은 창의 끝 버튼 - WebDAV 서버 추가 · 경로 입력 + Widget _headerMenu(_Pane pane) => PopupMenuButton( + tooltip: tr('경로 입력 · WebDAV'), + icon: const Icon(Icons.more_vert, size: 18), + padding: EdgeInsets.zero, + onSelected: (v) => v == 'path' ? _enterPath(pane) : _editDav(pane, null), + itemBuilder: (_) => [ + PopupMenuItem( + value: 'path', + child: ListTile(dense: true, leading: const Icon(Icons.edit_location_alt_outlined), title: Text(tr('경로 입력')))), + PopupMenuItem( + value: 'dav', child: ListTile(dense: true, leading: const Icon(Icons.cloud_outlined), title: Text(tr('WebDAV 서버 추가')))), + ], + ); + + /// 창 위 줄 앞: 뒤로 · 앞으로 · 즐겨찾기 + List _headerStart(_Pane pane) => [ // 75: 이 창의 뒤로 · 앞으로 (가 본 폴더) IconButton( tooltip: tr('뒤로 (Alt+←)'), @@ -3061,13 +3098,40 @@ class _ExplorerPageState extends State { color: Colors.amber), onPressed: () => _bookmarks(pane), ), - // 37: 지금 열어 둔 네트워크 공유 (\\NAS\공유) 도 탭으로 - for (final (path, label) in [ - ..._volumes, - for (final x in _panes) - if (Platform.isWindows && x.root.startsWith(r'\\') && !_volumes.any((v) => samePath(v.$1, x.root))) - (x.root, x.root), - ]) + ]; + + /// 202: 저장 장치 탭 - 넘치면 마우스 휠 · 끌기로도 옆으로. 비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로 + Widget _driveTabs(_Pane pane, {List lead = const []}) { + final all = [ + ..._volumes, + // 37: 지금 열어 둔 네트워크 공유 (\\NAS\공유) 도 탭으로 + for (final x in _panes) + if (Platform.isWindows && x.root.startsWith(r'\\') && !_volumes.any((v) => samePath(v.$1, x.root))) (x.root, x.root), + ]; + final tabs = [ + ...all.where((v) => _driveOk[v.$1] != false || samePath(pane.root, v.$1)), + ...all.where((v) => _driveOk[v.$1] == false && !samePath(pane.root, v.$1)), + ]; + return Listener( + // 마우스 휠 (위 · 아래) 을 옆으로 + onPointerSignal: (e) { + if (e is! PointerScrollEvent || !pane.tabScroll.hasClients) return; + final pos = pane.tabScroll.position; + final d = e.scrollDelta.dx != 0 ? e.scrollDelta.dx : e.scrollDelta.dy; + pane.tabScroll.jumpTo((pos.pixels + d).clamp(0.0, pos.maxScrollExtent)); + }, + child: ScrollConfiguration( + // 마우스로 끌어도 옆으로 (데스크톱은 기본으로 마우스 끌기가 꺼져 있음) + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad, PointerDeviceKind.stylus}, + scrollbars: false, + ), + child: ListView( + controller: pane.tabScroll, + scrollDirection: Axis.horizontal, + children: [ + ...lead, + for (final (path, label) in tabs) Padding( padding: const EdgeInsets.only(right: 4), child: TextButton.icon( @@ -3102,6 +3166,14 @@ class _ExplorerPageState extends State { onLongPress: isDav(path) ? () => _editDav(pane, path) : null, ), ), + ], + ), + ), + ); + } + + /// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력) + List _headerEnd(_Pane pane) => [ if (c.settings.webdavServers.isEmpty) TextButton.icon( style: TextButton.styleFrom( @@ -3126,9 +3198,7 @@ class _ExplorerPageState extends State { icon: const Icon(Icons.edit_location_alt_outlined, size: 16), onPressed: () => _enterPath(pane), ), - ], - ), - ); + ]; /// 경로를 직접 넣어 가기 (37). 146 · 37-2: 없는 폴더면 창을 닫지 않고 칸 아래에 알린다 (고쳐서 다시 넣을 수 있게) Future _enterPath(_Pane pane) async { @@ -3377,8 +3447,12 @@ String clashCompareText(NameClash x) { return '${vBasename(x.src)}: ${trf('보낼 것 {0} / 있는 것 {1}', [h == null ? '?' : one(h), one(t)])}$newer'; } -String _date(DateTime d) => - '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ' +/// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—" +String _date(DateTime d) => fileDateText(d); + +String fileDateText(DateTime d) => d.year <= 1970 + ? '—' + : '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ' '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; /// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때) diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index a71074e..77b1b09 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -12,6 +12,7 @@ import 'package:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; import 'support/recycle_leftovers.dart'; +import 'support/elevated.dart'; /// 48 · 154 · 155: 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고르고, 확인 창에 항목 이름이 보인다 void main() { @@ -256,6 +257,7 @@ void main() { testWidgets('E10: 권한 없는 폴더는 빈 폴더가 아니라 "읽을 수 없음 · 이유" 와 [다시 시도]', (tester) async { const locked = r'C:\System Volume Information'; if (!Platform.isWindows || !Directory(locked).existsSync()) return; + if (skipIfReadable(locked)) return; c.settings.explorerPaths = [locked, right]; ExplorerPage.debugVolumes = () => [(r'C:\', 'C:'), (p.rootPrefix(tmp.path), p.rootPrefix(tmp.path).substring(0, 2))]; tester.view.physicalSize = const Size(1600, 900); diff --git a/test/explorer_drives_test.dart b/test/explorer_drives_test.dart index 5d54c0c..2c724a4 100644 --- a/test/explorer_drives_test.dart +++ b/test/explorer_drives_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; @@ -70,6 +71,55 @@ void main() { expect(find.textContaining('R: · 연결 안 됨'), findsNothing); }); + testWidgets('202: 드라이브가 많아 탭이 넘쳐도 [경로 입력] · WebDAV 를 누를 수 있고, 휠로 탭이 옆으로 · 빈 드라이브는 뒤로', (tester) async { + // 빈 (연결 안 된) 드라이브가 앞쪽에 많은 PC + final empties = [for (final l in 'GHIJKLNOPS'.split('')) ('$l:\\', '$l:')]; + dead = {for (final e in empties) e.$1}; + ExplorerPage.debugVolumes = () => [...empties, (tmp.path, 'T:')]; + ExplorerPage.probeDrive = (path) => Future.value(!dead.contains(path)); + c.settings + ..explorerLayout = 'dual' + ..explorerOrientation = 'side'; + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + await settle(tester, () => find.textContaining('S: ·').evaluate().isNotEmpty); + // 넓은 창: 끝 버튼이 화면 안에 (눌러서 창이 뜬다) + final pathBtn = find.byTooltip('경로 입력'); + expect(pathBtn, findsNWidgets(2)); + for (final e in pathBtn.evaluate()) { + expect(tester.getRect(find.byWidget(e.widget).first).right, lessThanOrEqualTo(1280)); + } + await tester.tap(pathBtn.first); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap(find.text('취소').last); + await tester.pumpAndSettle(); + // 정상 드라이브 (T:) 가 빈 드라이브들보다 앞 + expect(tester.getTopLeft(find.text('T:').first).dx, lessThan(tester.getTopLeft(find.textContaining('G: ·').first).dx)); + // 휠 (위 · 아래) 로 탭 줄이 옆으로 (왼쪽 창의 T: 탭이 왼쪽으로 밀린다) + final before = tester.getTopLeft(find.text('T:').first).dx; + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(pointer.hover(tester.getCenter(find.text('T:').first))); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, 120))); + await tester.pump(); + expect(tester.getTopLeft(find.text('T:').first).dx, lessThan(before - 10)); + // 좁은 창 (위아래 두 창 · 폰): 끝 버튼은 [⋮] 하나로 늘 보임 → 경로 입력 + tester.view.physicalSize = const Size(560, 900); + await tester.pumpAndSettle(); + expect(find.byTooltip('경로 입력 · WebDAV'), findsWidgets); + await tester.tap(find.byTooltip('경로 입력 · WebDAV').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('경로 입력').last); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap(find.text('취소').last); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: '넘침 (RenderFlex overflow) 없음'); + }); + test('165: 드라이브 목록은 열어 보지 않고 Windows 에게 받는다 (빠름)', () { final sw = Stopwatch()..start(); final d = windowsDrives(); diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 3666a8a..bf7874e 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -11,6 +11,7 @@ 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/app_shell.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show formatSize; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:path/path.dart' as p; @@ -516,6 +517,14 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + test('203: 크기 · 바뀐 날을 모르면 (잠긴 시스템 파일) "-1B · 1970-01-01" 대신 "—"', () { + expect(formatSize(-1), '—'); + expect(formatSize(0), '0B'); + expect(fileDateText(DateTime(1970)), '—'); + expect(fileDateText(DateTime.fromMillisecondsSinceEpoch(0)), '—'); + expect(fileDateText(DateTime(2026, 10, 11, 9, 5)), '2026-10-11 09:05'); + }); + test('74: 패턴 (* · ? · 여러 개 · 대소문자 무시)', () { final re = globToRegExp('*.srt; ep??.mkv'); expect(['a.srt', 'A.SRT', 'ep01.mkv'].every(re.hasMatch), isTrue); diff --git a/test/file_ops_test.dart b/test/file_ops_test.dart index 31e4c28..9212a1f 100644 --- a/test/file_ops_test.dart +++ b/test/file_ops_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:path/path.dart' as p; +import 'support/elevated.dart'; void main() { late Directory tmp; @@ -205,7 +206,8 @@ void main() { final empty = Directory(p.join(tmp.path, 'empty'))..createSync(); expect(await listEntries(empty.path), isEmpty); await expectLater(listEntries(p.join(tmp.path, 'gone')), throwsA(isA())); - if (Platform.isWindows && Directory(r'C:\System Volume Information').existsSync()) { + if (Platform.isWindows && Directory(r'C:\System Volume Information').existsSync() && + !skipIfReadable(r'C:\System Volume Information')) { await expectLater(listEntries(r'C:\System Volume Information'), throwsA(isA())); } }); diff --git a/test/support/elevated.dart b/test/support/elevated.dart new file mode 100644 index 0000000..628f884 --- /dev/null +++ b/test/support/elevated.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// 권한 없는 폴더 시험 (E10) 은 그 폴더가 읽히는 환경 (Git Bash 에서 돌린 flutter test 등) 에서는 확인할 수 없다. +/// [locked] 를 읽을 수 있으면 건너뛰고 (이유를 시험 결과에 남긴다) true. +bool skipIfReadable(String locked) { + try { + Directory(locked).listSync(); + } on FileSystemException { + return false; // 읽을 수 없음 = 보통 권한 - 시험한다 + } + markTestSkipped('이 실행 환경에서는 $locked 가 읽혀 "권한 없음" 을 확인할 수 없음 ' + '(Git Bash · MSYS 는 백업 권한을 켠다 - PowerShell 에서 돌릴 것)'); + return true; +}