탐색기 탭 줄이 넘쳐도 경로 입력 · 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
parent
850b148d9e
commit
df508e3d2d
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -459,6 +459,7 @@
|
|||
"경로": "パス",
|
||||
"경로 입력": "パスを入力",
|
||||
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "パスを入力 (例: D:\\動画・\\\\NAS\\共有)",
|
||||
"경로 입력 · WebDAV": "パス入力 · WebDAV",
|
||||
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。",
|
||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
|
||||
"계속": "常時",
|
||||
|
|
|
|||
|
|
@ -459,6 +459,7 @@
|
|||
"경로": "路径",
|
||||
"경로 입력": "输入路径",
|
||||
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "输入路径 (例: D:\\视频 · \\\\NAS\\共享)",
|
||||
"경로 입력 · WebDAV": "输入路径 · WebDAV",
|
||||
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。",
|
||||
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
|
||||
"계속": "持续",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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')}';
|
||||
|
||||
/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>()));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue