From e9f7e5d8199f4274cf3c45499950f930322c3e85 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:46:20 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=ED=95=B4=EB=8F=84=20=EB=AA=A9=EB=A1=9D=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20(180)=20=C2=B7=20[=EC=83=88=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8]=20=EB=91=90=20=EC=B0=BD=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20(183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 창 (왼쪽 · 오른쪽 · 파일 목록) 마다 같은 GlobalKey: 창 크기 비율이 바뀌어 두 창이 가로 ↔ 세로로 바뀌어도 목록을 새로 만들지 않는다 (스크롤이 맨 위로 가던 것) - 읽는 중 막대는 목록 위에 겹쳐 그린다 (끼어들어 목록이 밀리지 않게) - 폴더 + 파일 목록: 다시 읽는 동안 앞의 목록을 그대로 (처음 읽을 때만 빙글이) - 복사 · 이동이 끝나면 표시만 지우고 선택 모드는 그대로. Esc 로 선택 모드 · 표시 풀기 (뒤로 키는 전과 같이) - 보던 폴더로 스크롤: 줄이 막 늘어난 때의 낡은 끝 값으로 자르지 않는다 - 183: [새로 고침] 은 두 창 모두, 목록을 비우지 않고 새로 읽은 것으로 바꾼다 - 시험: explorer_steady_test (매 프레임 자리 · 스크롤, 배치 전환, 선택 모드 · Esc, 두 창 새로 고침). 37-2 는 고정 횟수 대신 될 때까지 기다림 Co-Authored-By: Claude Opus 5.5 --- lib/ui/explorer_page.dart | 59 ++++++---- test/explorer_conflict_test.dart | 3 +- test/explorer_steady_test.dart | 178 +++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 test/explorer_steady_test.dart diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 5e762bc..57dc5fc 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -730,8 +730,9 @@ class _ExplorerPageState extends State { final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => samePath(r.entry.path, path)); if (i < 0) return; final pos = pane.scroll.position; - final want = (i * _look.rowHeight - pos.viewportDimension / 4).clamp(0.0, pos.maxScrollExtent); - pane.scroll.jumpTo(want); + // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. + // 넘치면 다음 배치 때 실제 끝으로 맞춰진다 + pane.scroll.jumpTo((i * _look.rowHeight - pos.viewportDimension / 4).clamp(0.0, double.infinity)); }); } @@ -1333,9 +1334,15 @@ class _ExplorerPageState extends State { case ExplorerButton.both: await _runRsync(right: true, left: true); case ExplorerButton.refresh: - pane.cache.clear(); - for (final d in pane.expanded.toList()) { - await _load(pane, d, force: true); + // 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게). + // 180: 목록을 먼저 비우지 않고 새로 읽은 것으로 바꾼다 (비우면 목록이 사라졌다 생겨 스크롤 · 자리가 바뀐다) + for (final x in _panes) { + final open = {...x.expanded, x.current}; + for (final d in open) { + await _load(x, d, force: true); + } + x.cache.removeWhere((k, _) => !open.contains(k)); + x.changed(); } case ExplorerButton.play: final t = _targets(pane); @@ -1930,8 +1937,8 @@ class _ExplorerPageState extends State { await job.done; final error = job.error; if (!mounted) return false; + // 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다) pane.marked.clear(); - if (_selecting) _setSelecting(false); await _refreshAll([dest, ...sources.map(p.dirname)]); // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 for (final x in _panes) { @@ -2684,6 +2691,10 @@ class _ExplorerPageState extends State { _button(ExplorerButton.delete); }, const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename), + // 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) + const SingleActivator(LogicalKeyboardKey.escape): () { + if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) _setSelecting(false); + }, const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up), // 75: 지금 창의 뒤로 · 앞으로 const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _travel(_panes[_active], forward: false), @@ -2795,21 +2806,23 @@ class _ExplorerPageState extends State { ); final middle = bar != null && (widget.rsync || s.explorerToolbar == 'middle'); final edge = bar != null && !widget.rsync && s.explorerToolbar == 'edge'; + // 180: 창마다 같은 열쇠 - 가로 ↔ 세로 배치가 바뀌어도 (창 크기 · 작업 현황이 열림) 창을 새로 만들지 않는다 (스크롤이 맨 위로 가지 않게) + Widget keep(int k, Widget w) => KeyedSubtree(key: _viewKeys[k], child: w); final children = [ if (dual) ...[ - Expanded(child: _paneView(0, foldersOnly: widget.rsync)), + Expanded(child: keep(0, _paneView(0, foldersOnly: widget.rsync))), if (middle) bar, - Expanded(child: _paneView(1, foldersOnly: widget.rsync)), + Expanded(child: keep(1, _paneView(1, foldersOnly: widget.rsync))), if (edge) bar, ] else if (split) ...[ // 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록 - Expanded(flex: 2, child: _paneView(0, foldersOnly: true)), + Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))), if (middle) bar, - Expanded(flex: 3, child: _listView(_panes[0])), + Expanded(flex: 3, child: keep(2, _listView(_panes[0]))), if (edge) bar, ] else ...[ if (middle) bar, - Expanded(child: _paneView(0)), + Expanded(child: keep(0, _paneView(0))), if (edge) bar, ], ]; @@ -2817,6 +2830,9 @@ class _ExplorerPageState extends State { }); } + /// 180: 왼쪽 창 · 오른쪽 창 · 파일 목록 (배치가 바뀌어도 같은 위젯 - [_body]) + final _viewKeys = [GlobalKey(), GlobalKey(), GlobalKey()]; + /// 75: 마우스 4 · 5번 - 그 창의 뒤로 · 앞으로 (그 창을 활성으로) PointerDownEvent? _navEvent; void _mouseTravel(int i, PointerDownEvent ev) { @@ -2940,16 +2956,18 @@ class _ExplorerPageState extends State { const Divider(height: 1), // 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄) ?_noticeArea([_accessNotice(pane), _errorNotice(pane, pane.current), _partialNotice(pane, pane.current)], box.maxHeight), - // 읽는 중 (WebDAV 서버를 기다릴 때 등) - if (pane.loading.isNotEmpty) const LinearProgressIndicator(minHeight: 2), if (look.columns && !foldersOnly) ExplorerColumnsHeader(style: look, markColumn: _selecting), Expanded( - child: ListView.builder( - controller: pane.scroll, - itemCount: rows.length, - itemExtent: look.rowHeight, - itemBuilder: (_, k) => _rowView(pane, rows[k], compact: foldersOnly), - ), + child: Stack(children: [ + ListView.builder( + controller: pane.scroll, + itemCount: rows.length, + itemExtent: look.rowHeight, + itemBuilder: (_, k) => _rowView(pane, rows[k], compact: foldersOnly), + ), + // 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다) + if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)), + ]), ), if (_selecting && !foldersOnly) _marksBar(pane), ])); @@ -2977,7 +2995,8 @@ class _ExplorerPageState extends State { ?_noticeArea([_accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)], box.maxHeight), if (look.columns) ExplorerColumnsHeader(style: look, markColumn: _selecting), Expanded( - child: pane.loading.contains(dir) + // 180: 다시 읽는 동안은 앞의 목록을 그대로 (빙글이로 바꾸면 목록이 새로 만들어져 스크롤이 맨 위로 간다) + child: pane.loading.contains(dir) && !pane.cache.containsKey(dir) ? const Center(child: CircularProgressIndicator()) : entries.isEmpty && !up ? Center(child: Text(tr('비어 있음'), style: const TextStyle(color: JjColors.textDim))) diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index b7bea20..a71074e 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -446,7 +446,8 @@ void main() { await tester.runAsync(() => tester.tap(find.text('새로 고침').first)); await settle(tester, () => find.textContaining('보던 폴더가 없어져 상위 폴더로 옮겼습니다').evaluate().isNotEmpty); expect(find.textContaining('보던 폴더가 없어져 상위 폴더로 옮겼습니다'), findsOneWidget); - await settle(tester, () => false, rounds: 10); + // 183: 두 창을 모두 새로 읽으므로 옮겨 가는 데 조금 더 걸릴 수 있다 - 될 때까지 + await settle(tester, () => find.textContaining(p.join(left, 'a')).evaluate().isEmpty); // 제목 (지금 경로) 도 남아 있는 가장 가까운 상위 (left) 로 expect(find.textContaining(p.join(left, 'a')), findsNothing); expect(find.text('doc.txt'), findsWidgets); diff --git a/test/explorer_steady_test.dart b/test/explorer_steady_test.dart new file mode 100644 index 0000000..984fdf9 --- /dev/null +++ b/test/explorer_steady_test.dart @@ -0,0 +1,178 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.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/app_shell.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:path/path.dart' as p; +import 'support/fake_recycle_bin.dart'; + +/// 180: 복사 · 이동이 시작 · 진행 · 끝나도 목록의 자리와 스크롤은 그대로 (다음 것을 누르려다 엉뚱한 것을 누르지 않게) +void main() { + late Directory tmp; + late String left, right; + late AppController c; + + setUp(() { + useTestRecycleBin(); + tmp = Directory.systemTemp.createTempSync('jj_steady_'); + left = p.join(tmp.path, 'left'); + right = p.join(tmp.path, 'right'); + for (var i = 0; i < 60; i++) { + File(p.join(left, 'f${i.toString().padLeft(2, '0')}.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('x' * 2000); + } + Directory(right).createSync(); + // 저장 장치는 시험 폴더 하나 (실제 Temp 폴더의 많은 항목 사이에서 찾지 않게) + ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')]; + c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: NoopShell())); + c.settings + ..explorerPaths = [left, right] + ..copyMethodFile = 'builtin' + ..copyMethodFolder = 'builtin'; + }); + tearDown(() async { + ExplorerPage.debugVolumes = null; + for (var i = 0;; i++) { + try { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + return; + } on FileSystemException { + if (i >= 50) rethrow; + await Future.delayed(const Duration(milliseconds: 100)); + } + } + }); + + for (final layout in ['dual', 'split']) { + for (final selecting in [false, true]) { + testWidgets('180: 복사해도 자리 · 스크롤 그대로 ($layout${selecting ? ' · 선택 모드' : ''})', (tester) async { + c.settings.explorerLayout = layout; + 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('f05.txt').evaluate().isNotEmpty); + // 왼쪽 목록을 조금 내려 둔다 + final list = find.ancestor(of: find.text('f05.txt'), matching: find.byType(Scrollable)).first; + await tester.drag(find.text('f05.txt'), const Offset(0, -200)); + await tester.pumpAndSettle(); + final probe = find.text('f07.txt'); + expect(probe, findsOneWidget); + final where = tester.getTopLeft(probe); + final at = tester.state(list).position.pixels; + expect(at, greaterThan(0)); + if (selecting) { + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('f09.txt'))); + } else { + await act(tester, () => tester.tap(find.text('f09.txt'))); + } + await settle(tester, () => false, rounds: 10); + final before = tester.getTopLeft(probe); + expect(before.dy, where.dy, reason: '고르기만 해서는 움직이지 않음'); + final split = layout == 'split'; + if (split) { + // "폴더 + 파일 목록" 은 다른 창이 없어 복사는 붙여넣기로 - 같은 다시 읽기를 [새로 고침] 으로 + File(p.join(left, 'zz_new.txt')).writeAsStringSync('n'); + await act(tester, () => tester.tap(find.text('새로 고침').first)); + } else { + await act(tester, () => tester.tap(find.text('복사').first)); + await tester.pumpAndSettle(); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + } + // 시작 · 진행 · 끝 (진행 막대가 내려갈 때까지) 내내 같은 자리 + final seen = {}; + for (var i = 0; i < 300; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + if (probe.evaluate().isEmpty) { + seen.add(const Offset(-1, -1)); + } else { + seen.add(tester.getTopLeft(probe)); + } + if ((split || File(p.join(right, 'f09.txt')).existsSync()) && find.byType(LinearProgressIndicator).evaluate().isEmpty && i > 100) break; + } + if (!split) expect(File(p.join(right, 'f09.txt')).existsSync(), isTrue); + expect(seen, {before}, reason: '목록 자리가 그대로'); + expect(tester.state(list).position.pixels, at, reason: '스크롤 그대로'); + if (selecting) expect(find.text('선택 끝'), findsWidgets, reason: '선택 모드는 그대로 (표시만 지움)'); + if (selecting) { + // Esc 로 바로 나온다 + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.text('선택 끝'), findsNothing); + } + ScaffoldMessenger.of(tester.element(find.byType(Scaffold).first)).clearSnackBars(); + await tester.pump(); + }); + } + } + + testWidgets('180: 창 크기가 바뀌어 두 창이 가로 ↔ 세로로 바뀌어도 스크롤 그대로', (tester) async { + c.settings + ..explorerLayout = 'dual' + ..explorerOrientation = 'auto'; + 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('f05.txt').evaluate().isNotEmpty); + final list = find.ancestor(of: find.text('f05.txt'), matching: find.byType(Scrollable)).first; + await tester.drag(find.text('f05.txt'), const Offset(0, -200)); + await tester.pumpAndSettle(); + final at = tester.state(list).position.pixels; + expect(at, greaterThan(0)); + final w = tester.getSize(list).width; + // 세로로 (위 · 아래 두 창) + tester.view.physicalSize = const Size(900, 1200); + await settle(tester, () => false, rounds: 10); + expect(tester.getSize(find.ancestor(of: find.text('f07.txt'), matching: find.byType(Scrollable)).first).width, isNot(w), reason: '배치가 바뀜'); + expect(tester.state(find.ancestor(of: find.text('f07.txt'), matching: find.byType(Scrollable)).first).position.pixels, at); + // 다시 가로로 + tester.view.physicalSize = const Size(1280, 800); + await settle(tester, () => false, rounds: 10); + expect(tester.state(find.ancestor(of: find.text('f07.txt'), matching: find.byType(Scrollable)).first).position.pixels, at); + }); + + testWidgets('183: [새로 고침] 은 두 창 모두 (같은 폴더를 보는 다른 창에 밖에서 지운 파일이 남지 않게)', (tester) async { + c.settings + ..explorerLayout = 'dual' + ..explorerPaths = [left, left]; + 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('f03.txt').evaluate().length == 2); + expect(find.text('f03.txt'), findsNWidgets(2)); + await tester.runAsync(() => File(p.join(left, 'f03.txt')).delete()); + await act(tester, () => tester.tap(find.text('새로 고침').first)); + await settle(tester, () => find.text('f03.txt').evaluate().isEmpty); + expect(find.text('f03.txt'), findsNothing); + }); +} + +Finder markOf(String name) => find.descendant( + of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, + matching: find.byTooltip('표시 (여러 개 고르기)'), + ); + +Future act(WidgetTester tester, Future Function() f) async { + await tester.runAsync(f); + await tester.pump(); +} + +Future settle(WidgetTester tester, bool Function() done, {int rounds = 300}) async { + for (var i = 0; i < rounds; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + if (done()) break; + } + await tester.pump(const Duration(milliseconds: 300)); +}