165 탐색기 첫 열기 멈춤: 드라이브 목록은 GetLogicalDrives (열어 보지 않음) · 드라이브마다 비동기 확인 · 연결 안 됨 ⟳

- 예전: 드라이브 26개를 isDirectorySync 로 열어 봄 → 끊긴 · 느린 NAS 드라이브에서 화면이 멈춤 (이 PC 11,181ms → 2ms)
- 끊긴 드라이브도 숨기지 않고 탭에 "X: · 연결 안 됨 ⟳" - 누르면 다시 연결 (다시 확인), 안 되면 이유
- 네트워크 드라이브는 GetDriveTypeW 로 알아 네트워크 아이콘
- 지난 폴더는 비동기로 확인 (2초 넘으면 열어 보고, 못 읽으면 그 창에 이유)
- 시험: 응답 없는 드라이브가 있어도 첫 화면이 바로 뜬다 · 연결 안 됨 · 다시 연결

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-09 18:22:02 +09:00
parent a878e3b1cc
commit 7d76af382d
6 changed files with 189 additions and 11 deletions

View File

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

View File

@ -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} 回目)",

View File

@ -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} 次)",

View File

@ -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 {

View File

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

View File

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