복사 · 이동해도 목록 자리 · 스크롤 그대로 (180) · [새로 고침] 두 창 모두 (183)

- 창 (왼쪽 · 오른쪽 · 파일 목록) 마다 같은 GlobalKey: 창 크기 비율이 바뀌어 두 창이 가로 ↔ 세로로 바뀌어도 목록을 새로 만들지 않는다 (스크롤이 맨 위로 가던 것)
- 읽는 중 막대는 목록 위에 겹쳐 그린다 (끼어들어 목록이 밀리지 않게)
- 폴더 + 파일 목록: 다시 읽는 동안 앞의 목록을 그대로 (처음 읽을 때만 빙글이)
- 복사 · 이동이 끝나면 표시만 지우고 선택 모드는 그대로. Esc 로 선택 모드 · 표시 풀기 (뒤로 키는 전과 같이)
- 보던 폴더로 스크롤: 줄이 막 늘어난 때의 낡은 끝 값으로 자르지 않는다
- 183: [새로 고침] 은 두 창 모두, 목록을 비우지 않고 새로 읽은 것으로 바꾼다
- 시험: explorer_steady_test (매 프레임 자리 · 스크롤, 배치 전환, 선택 모드 · Esc, 두 창 새로 고침). 37-2 는 고정 횟수 대신 될 때까지 기다림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-10 08:46:20 +09:00
parent 5e5182178b
commit e9f7e5d819
3 changed files with 219 additions and 21 deletions

View File

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

View File

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

View File

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