탐색기 탭 줄이 넘쳐도 경로 입력 · WebDAV (202) · 모르는 크기 · 날짜 "—" (203) · E10 시험 건너뛰기

- 202 (관리자 지시, 사용 막힘): 빈 드라이브가 많은 PC 에서 창 위 줄이 넘쳐 [경로 입력] · WebDAV 를 누를 수 없었다.
  [← → ★] · 저장 장치 탭 (이것만 옆으로, 마우스 휠 · 끌기) · [WebDAV · 경로 입력] (늘 보임) 으로 나눈다.
  비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로. 창이 320 보다 좁으면 끝 버튼은 [⋮] 하나로.
- 203: 잠긴 시스템 파일의 크기 · 바뀐 날 ("-1B · 1970-01-01") 은 "—"
- 시험: 권한 없는 폴더 (E10) 가 읽히는 환경 (Git Bash · MSYS 는 백업 권한을 켬) 에서는 이유를 남기고 건너뜀.
  explorer_parallel_test 37 · 122 는 202 때문에 [경로 입력] 을 못 찾던 것 - 이제 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-11 20:53:32 +09:00
parent 850b148d9e
commit df508e3d2d
10 changed files with 175 additions and 17 deletions

View File

@ -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",

View File

@ -459,6 +459,7 @@
"경로": "パス",
"경로 입력": "パスを入力",
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "パスを入力 (例: D:\\動画・\\\\NAS\\共有)",
"경로 입력 · WebDAV": "パス入力 · WebDAV",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
"계속": "常時",

View File

@ -459,6 +459,7 @@
"경로": "路径",
"경로 입력": "输入路径",
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "输入路径 (例: D:\\视频 · \\\\NAS\\共享)",
"경로 입력 · WebDAV": "输入路径 · WebDAV",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
"계속": "持续",

View File

@ -807,6 +807,8 @@ Stream<FileEntry> 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';

View File

@ -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<ExplorerPage> {
}
/// 창 위쪽: 저장 장치 고르기
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [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<String>(
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<Widget> _headerStart(_Pane pane) => [
// 75: 이 창의 뒤로 · 앞으로 (가 본 폴더)
IconButton(
tooltip: tr('뒤로 (Alt+←)'),
@ -3061,13 +3098,40 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<Widget> 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<ExplorerPage> {
onLongPress: isDav(path) ? () => _editDav(pane, path) : null,
),
),
],
),
),
);
}
/// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력)
List<Widget> _headerEnd(_Pane pane) => [
if (c.settings.webdavServers.isEmpty)
TextButton.icon(
style: TextButton.styleFrom(
@ -3126,9 +3198,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
icon: const Icon(Icons.edit_location_alt_outlined, size: 16),
onPressed: () => _enterPath(pane),
),
],
),
);
];
/// 경로를 직접 넣어 가기 (37). 146 · 37-2: 없는 폴더면 창을 닫지 않고 칸 아래에 알린다 (고쳐서 다시 넣을 수 있게)
Future<void> _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')}';
/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때)

View File

@ -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);

View File

@ -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();

View File

@ -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);

View File

@ -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<FileSystemException>()));
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<FileSystemException>()));
}
});

View File

@ -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;
}