복사 · 이동해도 목록 자리 · 스크롤 그대로 (180) · [새로 고침] 두 창 모두 (183)
- 창 (왼쪽 · 오른쪽 · 파일 목록) 마다 같은 GlobalKey: 창 크기 비율이 바뀌어 두 창이 가로 ↔ 세로로 바뀌어도 목록을 새로 만들지 않는다 (스크롤이 맨 위로 가던 것) - 읽는 중 막대는 목록 위에 겹쳐 그린다 (끼어들어 목록이 밀리지 않게) - 폴더 + 파일 목록: 다시 읽는 동안 앞의 목록을 그대로 (처음 읽을 때만 빙글이) - 복사 · 이동이 끝나면 표시만 지우고 선택 모드는 그대로. Esc 로 선택 모드 · 표시 풀기 (뒤로 키는 전과 같이) - 보던 폴더로 스크롤: 줄이 막 늘어난 때의 낡은 끝 값으로 자르지 않는다 - 183: [새로 고침] 은 두 창 모두, 목록을 비우지 않고 새로 읽은 것으로 바꾼다 - 시험: explorer_steady_test (매 프레임 자리 · 스크롤, 배치 전환, 선택 모드 · Esc, 두 창 새로 고침). 37-2 는 고정 횟수 대신 될 때까지 기다림 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>fix_mkv
parent
5e5182178b
commit
e9f7e5d819
|
|
@ -730,8 +730,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
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<ExplorerPage> {
|
|||
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<ExplorerPage> {
|
|||
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<ExplorerPage> {
|
|||
_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<ExplorerPage> {
|
|||
);
|
||||
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 = <Widget>[
|
||||
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<ExplorerPage> {
|
|||
});
|
||||
}
|
||||
|
||||
/// 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<ExplorerPage> {
|
|||
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<ExplorerPage> {
|
|||
?_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)))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void>.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<ScrollableState>(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 = <Offset>{};
|
||||
for (var i = 0; i < 300; i++) {
|
||||
await tester.runAsync(() => Future<void>.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<ScrollableState>(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<ScrollableState>(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<ScrollableState>(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<ScrollableState>(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<void> act(WidgetTester tester, Future<void> Function() f) async {
|
||||
await tester.runAsync(f);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> settle(WidgetTester tester, bool Function() done, {int rounds = 300}) async {
|
||||
for (var i = 0; i < rounds; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
|
||||
await tester.pump();
|
||||
if (done()) break;
|
||||
}
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
Loading…
Reference in New Issue