165 탐색기 첫 열기 멈춤: 드라이브 목록은 GetLogicalDrives (열어 보지 않음) · 드라이브마다 비동기 확인 · 연결 안 됨 ⟳
- 예전: 드라이브 26개를 isDirectorySync 로 열어 봄 → 끊긴 · 느린 NAS 드라이브에서 화면이 멈춤 (이 PC 11,181ms → 2ms) - 끊긴 드라이브도 숨기지 않고 탭에 "X: · 연결 안 됨 ⟳" - 누르면 다시 연결 (다시 확인), 안 되면 이유 - 네트워크 드라이브는 GetDriveTypeW 로 알아 네트워크 아이콘 - 지난 폴더는 비동기로 확인 (2초 넘으면 열어 보고, 못 읽으면 그 창에 이유) - 시험: 응답 없는 드라이브가 있어도 첫 화면이 바로 뜬다 · 연결 안 됨 · 다시 연결 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
a878e3b1cc
commit
7d76af382d
|
|
@ -294,6 +294,7 @@
|
|||
"{0} 안의 파일": "Files in {0}",
|
||||
"{0} 에 보내는 중": "Sending to {0}",
|
||||
"{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})",
|
||||
"{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "Cannot connect to {0}. Check that the network and NAS are on.",
|
||||
"{0} 에서 그리는 중 ({1}초)": "Drawing on {0} ({1} s)",
|
||||
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
|
||||
"{0} 외 {1}개": "{0} and {1} more",
|
||||
|
|
@ -1132,6 +1133,7 @@
|
|||
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)",
|
||||
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "Too many wrong tries. You can try again in {0} s",
|
||||
"연결 실패: {0}": "Connection failed: {0}",
|
||||
"연결 안 됨": "Not connected",
|
||||
"연결 확인": "Test connection",
|
||||
"연결됨: 항목 {0}개{1}": "Connected: {0} items{1}",
|
||||
"연결이 끊겨 다시 잇는 중 ({0}번째)": "Connection lost, reconnecting (attempt {0})",
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@
|
|||
"{0} 안의 파일": "{0} 内のファイル",
|
||||
"{0} 에 보내는 중": "{0} に送信中",
|
||||
"{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)",
|
||||
"{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "{0} に接続できません。ネットワーク・NAS がオンか確認してください。",
|
||||
"{0} 에서 그리는 중 ({1}초)": "{0} で描画中 ({1} 秒)",
|
||||
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
|
||||
"{0} 외 {1}개": "{0} ほか {1} 件",
|
||||
|
|
@ -1132,6 +1133,7 @@
|
|||
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
|
||||
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "何度も間違えたため {0} 秒後に再入力できます",
|
||||
"연결 실패: {0}": "接続失敗: {0}",
|
||||
"연결 안 됨": "未接続",
|
||||
"연결 확인": "接続テスト",
|
||||
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
|
||||
"연결이 끊겨 다시 잇는 중 ({0}번째)": "接続が切れたため再接続中 ({0} 回目)",
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@
|
|||
"{0} 안의 파일": "{0} 中的文件",
|
||||
"{0} 에 보내는 중": "正在发送到 {0}",
|
||||
"{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)",
|
||||
"{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "无法连接到 {0}。请确认网络和 NAS 已开启。",
|
||||
"{0} 에서 그리는 중 ({1}초)": "正在 {0} 上绘制 ({1} 秒)",
|
||||
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
|
||||
"{0} 외 {1}개": "{0} 等 {1} 个",
|
||||
|
|
@ -1132,6 +1133,7 @@
|
|||
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
|
||||
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入",
|
||||
"연결 실패: {0}": "连接失败: {0}",
|
||||
"연결 안 됨": "未连接",
|
||||
"연결 확인": "测试连接",
|
||||
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
|
||||
"연결이 끊겨 다시 잇는 중 ({0}번째)": "连接中断,正在重新连接 (第 {0} 次)",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
|
|
@ -67,11 +69,37 @@ Future<List<FileEntry>> listEntries(String dir, {bool showHidden = false}) async
|
|||
return out;
|
||||
}
|
||||
|
||||
/// 저장 장치 (맨 위 폴더): Windows 는 드라이브 (C:\ …), 그 밖은 주어진 목록
|
||||
List<(String path, String label)> windowsDrives() => [
|
||||
for (final c in 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split(''))
|
||||
if (FileSystemEntity.isDirectorySync('$c:\\')) ('$c:\\', '$c:'),
|
||||
];
|
||||
/// 저장 장치 (맨 위 폴더): Windows 는 드라이브 (C:\ …), 그 밖은 주어진 목록.
|
||||
/// 165: 드라이브 문자는 Windows 가 알려 주는 목록 (GetLogicalDrives) 으로 - 드라이브마다 열어 보지 않는다
|
||||
/// (끊긴 · 느린 네트워크 드라이브 (NAS) 를 열어 보느라 탐색기가 10초씩 멈췄다)
|
||||
List<(String path, String label)> windowsDrives() {
|
||||
int mask;
|
||||
try {
|
||||
mask = DynamicLibrary.open('kernel32.dll').lookupFunction<Uint32 Function(), int Function()>('GetLogicalDrives')();
|
||||
} catch (_) {
|
||||
mask = 0;
|
||||
}
|
||||
if (mask == 0) return [(r'C:\', 'C:')];
|
||||
return [
|
||||
for (final c in 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split(''))
|
||||
if (mask & (1 << (c.codeUnitAt(0) - 65)) != 0) ('$c:\\', '$c:'),
|
||||
];
|
||||
}
|
||||
|
||||
/// 165: 네트워크 드라이브인지 (GetDriveTypeW = DRIVE_REMOTE). 드라이브를 열어 보지 않는다
|
||||
bool isNetworkDrive(String root) {
|
||||
if (!Platform.isWindows) return false;
|
||||
final w = root.toNativeUtf16();
|
||||
try {
|
||||
final f = DynamicLibrary.open('kernel32.dll')
|
||||
.lookupFunction<Uint32 Function(Pointer<Utf16>), int Function(Pointer<Utf16>)>('GetDriveTypeW');
|
||||
return f(w) == 4;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
calloc.free(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// 48: 받는 폴더에 같은 이름이 있을 때
|
||||
enum NameConflict {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,14 @@ class ExplorerPage extends StatefulWidget {
|
|||
final bool rsync;
|
||||
const ExplorerPage({super.key, required this.c, this.rsync = false});
|
||||
|
||||
/// 165: 드라이브를 열 수 있는지 (시험에서 응답 없는 드라이브로 바꾼다). 화면을 멈추지 않는 비동기
|
||||
@visibleForTesting
|
||||
static Future<bool> Function(String path) probeDrive = (path) => Directory(path).exists();
|
||||
|
||||
/// 시험: 이 기기의 저장 장치 목록 (null 이면 실제 목록)
|
||||
@visibleForTesting
|
||||
static List<(String, String)> Function()? debugVolumes;
|
||||
|
||||
static const routeName = 'explorer';
|
||||
static const rsyncRouteName = 'rsync';
|
||||
static int _open = 0, _openRsync = 0;
|
||||
|
|
@ -188,6 +196,33 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
/// 이 기기의 저장 장치: (경로, 이름)
|
||||
List<(String, String)> _local = [];
|
||||
|
||||
/// 165: 드라이브마다 따로 뒤에서 확인 (Windows). null = 확인 중, true = 열 수 있음, false = 연결 안 됨
|
||||
final Map<String, bool?> _driveOk = {};
|
||||
final Set<String> _networkDrives = {};
|
||||
|
||||
Future<void> _probeDrive(String path) async {
|
||||
if (mounted) setState(() => _driveOk[path] = null);
|
||||
bool ok;
|
||||
try {
|
||||
ok = await ExplorerPage.probeDrive(path);
|
||||
} catch (_) {
|
||||
ok = false;
|
||||
}
|
||||
if (mounted) setState(() => _driveOk[path] = ok);
|
||||
}
|
||||
|
||||
/// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다
|
||||
Future<void> _reconnect(_Pane pane, String path) async {
|
||||
await _probeDrive(path);
|
||||
if (!mounted) return;
|
||||
if (_driveOk[path] == true) {
|
||||
pane.cache.remove(path);
|
||||
await _goTo(pane, path);
|
||||
} else {
|
||||
_snack(trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path]));
|
||||
}
|
||||
}
|
||||
|
||||
/// 저장 장치 + WebDAV 서버 (환경 설정 > 파일 탐색기 > WebDAV): (경로, 이름)
|
||||
List<(String, String)> get _volumes => [
|
||||
..._local,
|
||||
|
|
@ -235,8 +270,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
if (Platform.isAndroid) unawaited(_checkAccess());
|
||||
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths;
|
||||
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
|
||||
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
|
||||
final reach = await Future.wait(
|
||||
[for (var i = 0; i < 2; i++) i < saved.length ? _reachableAsync(saved[i]) : Future.value(false)]);
|
||||
final wants = [
|
||||
for (var i = 0; i < 2; i++) i < saved.length && _reachable(saved[i]) ? saved[i] : _local[0].$1,
|
||||
for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _local[0].$1,
|
||||
];
|
||||
for (var i = 0; i < 2; i++) {
|
||||
_panes[i]
|
||||
|
|
@ -246,6 +284,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _ready = true);
|
||||
// 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로)
|
||||
if (Platform.isWindows) {
|
||||
for (final (path, _) in _local) {
|
||||
if (isNetworkDrive(path)) _networkDrives.add(path);
|
||||
unawaited(_probeDrive(path));
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < 2; i++) {
|
||||
final pane = _panes[i];
|
||||
unawaited(_goTo(pane, wants[i], remember: false).then((_) => _reveal(pane, pane.current)));
|
||||
|
|
@ -458,6 +503,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
|
||||
Future<List<(String, String)>> _loadVolumes() async {
|
||||
if (ExplorerPage.debugVolumes case final f?) return f();
|
||||
if (Platform.isWindows) return windowsDrives();
|
||||
if (Platform.isAndroid) {
|
||||
final v = await AndroidAccess.volumes();
|
||||
|
|
@ -487,6 +533,16 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
? DavRegistry.server(DavPath.parse(path).server) != null
|
||||
: Directory(path).existsSync();
|
||||
|
||||
/// [_reachable] 을 화면을 멈추지 않고 (2초 넘게 걸리면 있다고 보고 열어 본다)
|
||||
Future<bool> _reachableAsync(String path) async {
|
||||
if (isDav(path)) return _reachable(path);
|
||||
try {
|
||||
return await Directory(path).exists().timeout(const Duration(seconds: 2), onTimeout: () => true);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _volumeOf(String path) {
|
||||
if (isDav(path)) return '$davScheme${DavPath.parse(path).server}/';
|
||||
// 37: Windows 네트워크 공유 (\\NAS\공유) 는 그 공유가 맨 위
|
||||
|
|
@ -2444,14 +2500,23 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
icon: Icon(
|
||||
isDav(path)
|
||||
? Icons.cloud_outlined
|
||||
: Platform.isWindows
|
||||
? Icons.storage
|
||||
: Icons.sd_storage_outlined,
|
||||
: _networkDrives.contains(path)
|
||||
? Icons.lan_outlined
|
||||
: Platform.isWindows
|
||||
? Icons.storage
|
||||
: Icons.sd_storage_outlined,
|
||||
size: 16),
|
||||
label: Text(label),
|
||||
// 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기)
|
||||
label: _driveOk[path] == false
|
||||
? Text('$label · ${tr('연결 안 됨')} ⟳', style: const TextStyle(color: Colors.orangeAccent))
|
||||
: Text(label),
|
||||
onPressed: () {
|
||||
setState(() => _active = _panes.indexOf(pane));
|
||||
_goTo(pane, path);
|
||||
if (_driveOk[path] == false) {
|
||||
unawaited(_reconnect(pane, path));
|
||||
} else {
|
||||
_goTo(pane, path);
|
||||
}
|
||||
},
|
||||
onLongPress: isDav(path) ? () => _editDav(pane, path) : null,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/file_ops.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:path/path.dart' as p;
|
||||
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
late AppController c;
|
||||
late Completer<bool> stuck;
|
||||
var dead = <String>{};
|
||||
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_drives_');
|
||||
File(p.join(tmp.path, 'left', 'doc.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('x');
|
||||
Directory(p.join(tmp.path, 'right')).createSync();
|
||||
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings.explorerPaths = [p.join(tmp.path, 'left'), p.join(tmp.path, 'right')];
|
||||
stuck = Completer<bool>();
|
||||
dead = {r'R:\'};
|
||||
// T: 시험 폴더 (정상) · Q: 응답 없는 네트워크 드라이브 · R: 연결 안 된 드라이브
|
||||
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:'), (r'Q:\', 'Q:'), (r'R:\', 'R:')];
|
||||
ExplorerPage.probeDrive = (path) => path == r'Q:\' ? stuck.future : Future.value(!dead.contains(path));
|
||||
});
|
||||
tearDown(() {
|
||||
ExplorerPage.debugVolumes = null;
|
||||
ExplorerPage.probeDrive = (path) => Directory(path).exists();
|
||||
if (!stuck.isCompleted) stuck.complete(false);
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<void> settle(WidgetTester tester, bool Function() done) async {
|
||||
for (var i = 0; i < 50 && !done(); i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('165: 응답 없는 드라이브가 있어도 첫 화면이 바로 뜨고, 연결 안 된 드라이브는 숨기지 않고 "연결 안 됨" · 누르면 다시 연결', (tester) async {
|
||||
tester.view.physicalSize = const Size(1600, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final sw = Stopwatch()..start();
|
||||
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
|
||||
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
|
||||
// Q: 는 끝내 대답하지 않지만 목록 · 두 창은 바로 (Q: 를 기다리지 않음)
|
||||
expect(find.text('doc.txt'), findsOneWidget);
|
||||
expect(sw.elapsed, lessThan(const Duration(seconds: 5)));
|
||||
expect(find.text('Q:'), findsWidgets, reason: '응답 없는 드라이브도 목록에 (확인 중)');
|
||||
// R: 는 숨기지 않고 "연결 안 됨"
|
||||
await settle(tester, () => find.textContaining('연결 안 됨').evaluate().isNotEmpty);
|
||||
expect(find.textContaining('R: · 연결 안 됨'), findsWidgets);
|
||||
// 누르면 다시 연결: 아직 안 되면 알림
|
||||
await tester.tap(find.textContaining('R: · 연결 안 됨').first);
|
||||
await settle(tester, () => find.textContaining('R:\\ 에 연결할 수 없습니다').evaluate().isNotEmpty);
|
||||
expect(find.textContaining(r'R:\ 에 연결할 수 없습니다'), findsOneWidget);
|
||||
// 연결되면 "연결 안 됨" 이 사라진다
|
||||
dead = {};
|
||||
await tester.tap(find.textContaining('R: · 연결 안 됨').first);
|
||||
await settle(tester, () => find.textContaining('R: · 연결 안 됨').evaluate().isEmpty);
|
||||
expect(find.textContaining('R: · 연결 안 됨'), findsNothing);
|
||||
});
|
||||
|
||||
test('165: 드라이브 목록은 열어 보지 않고 Windows 에게 받는다 (빠름)', () {
|
||||
final sw = Stopwatch()..start();
|
||||
final d = windowsDrives();
|
||||
expect(sw.elapsedMilliseconds, lessThan(500));
|
||||
expect(d.map((e) => e.$2), contains('C:'));
|
||||
}, skip: !Platform.isWindows);
|
||||
}
|
||||
Loading…
Reference in New Issue