diff --git a/integration_test/explorer_use_check_test.dart b/integration_test/explorer_use_check_test.dart index 58e55e1..881ea95 100644 --- a/integration_test/explorer_use_check_test.dart +++ b/integration_test/explorer_use_check_test.dart @@ -154,7 +154,11 @@ void main() { : name.contains('.') ? name.substring(0, name.lastIndexOf('.')) : '[$name]'; - Finder txt(String name) => leftOf(find.text(shown(name))); + // 좁은 TC 줄 (확장자 칸 없음) 은 이름에 확장자를 붙인다 - 두 꼴 가운데 보이는 것 + Finder txt(String name) { + final a = find.text(shown(name)); + return leftOf(a.evaluate().isNotEmpty ? a : find.text(name)); + } Finder row(String name) => find.ancestor(of: txt(name), matching: find.byType(InkWell)).first; Finder tip(String s) => find.byWidgetPredicate((x) => x is Tooltip && (x.message ?? '').startsWith(s)).first; // 줄 끝 표시 단추: 이름 글과 같은 높이의 것 @@ -264,6 +268,76 @@ void main() { await t.tap(txt('report.pdf')); await settle(4); }); + // TC 전용 (관리자 지시 10/23): 목록 들어가고 나오기 (..) · 아래 단추 줄 F5 복사 · F6 이동 · F8 삭제 · 지운 뒤 초점 + if (style == 'totalcmd') { + // 복사 · 이동 · 지우기는 받는 폴더의 시각을 지금으로 바꾼다 - TC 는 폴더 날짜 칸이 있어 다시 고정하고 새로 읽는다 (Ctrl+R) + Future fixDirTimes() async { + await t.runAsync(() => Process.run('powershell', [ + '-NoProfile', + '-Command', + "Get-ChildItem -LiteralPath '${tmp.path}' -Recurse -Directory | ForEach-Object { \$_.LastWriteTime = [datetime]'2026-10-22 09:00' }", + ])); + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.sendKeyEvent(LogicalKeyboardKey.keyR); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await settle(); + } + + Future fkey(String tip) async { + await t.tap(find.byTooltip(tip)); + await settle(); + if (find.byType(Dialog).evaluate().isNotEmpty) await okDialog(); + await jobsGone(); + await fixDirTimes(); + } + + await noticeTimesOut(); + // 선택 모드면 끈다 (선택 모드의 누르기는 표시 - 들어가기를 보려면 끈다) + await step('TC - 선택 끝', () async { + if (find.text('선택 끝').evaluate().isNotEmpty) await t.tap(find.text('선택 끝').first); + }); + // 맨 위 (Videos) 로: [Docs] 가 왼쪽 창에 보일 때까지 Backspace (위 폴더 키) - 두 번 누르기는 위로 간 뒤 같은 자리의 다른 줄을 열었다 + await step('TC - Backspace 로 맨 위까지', () async { + // 왼쪽 창을 지금 창으로 (창 위 남은 공간 줄 - 누르기 동작이 없는 자리) + await t.tap(leftOf(find.textContaining('남은'))); + await settle(10); + bool top() => find.text(shown('Docs')).evaluate().any((e) => t.getCenter(find.byWidget(e.widget).first).dx < w / 2); + for (var i = 0; i < 4 && !top(); i++) { + await t.sendKeyEvent(LogicalKeyboardKey.backspace); + await settle(); + } + }); + // 들어가기 · 나오기 (".." 두 번 누르기는 단위 약속 시험 explorer_tc_look_test 가 본다 - 이 실제 시간 시험에서는 두 번 누르기가 + // 열기로 잡히지 않았다): 줄 메뉴 [열기] 로 들어가고, 위 폴더 키 (Backspace) 로 나온다 + await step('TC - [Docs] 메뉴 열기 (들어가기)', () async { + await t.tap(txt('Docs'), buttons: kSecondaryMouseButton); + await settle(); + await t.tap(find.text('열기').last); + await settle(); + }); + await step('TC - Backspace (나오기)', () async { + await t.sendKeyEvent(LogicalKeyboardKey.backspace); + await settle(); + }); + await step('TC - notes 고르고 F5 복사', () async { + await t.tap(txt('notes.txt')); + await settle(10); // 한 번 누르기는 두 번 누르기 기다림 (약 300ms) 뒤에 처리된다 + await fkey('F5 복사'); + }); + await noticeTimesOut(); + await step('TC - report 고르고 F6 이동', () async { + await t.tap(txt('report.pdf')); + await settle(10); // 한 번 누르기는 두 번 누르기 기다림 (약 300ms) 뒤에 처리된다 + await fkey('F6 이동'); + }); + await noticeTimesOut(); + await step('TC - notes 고르고 F8 삭제 (지운 뒤 초점)', () async { + await t.tap(txt('notes.txt')); + await settle(10); // 한 번 누르기는 두 번 누르기 기다림 (약 300ms) 뒤에 처리된다 + await fkey('F8 삭제'); + }); + files('TC 단계 뒤'); + } // 끝 상태: 실제 파일 final left = [ for (final e in tmp.listSync(recursive: true)) diff --git a/lib/core/explorer/focus_after_delete.dart b/lib/core/explorer/focus_after_delete.dart new file mode 100644 index 0000000..ab82e5c --- /dev/null +++ b/lib/core/explorer/focus_after_delete.dart @@ -0,0 +1,32 @@ +/// 약속 (관리자 결정 10/23, Total Commander · X-plore 와 같음): 지운 뒤 지금 줄 (초점) 을 어디로 둘지. +/// [shown] 은 지우기 전 그 폴더에 보이던 차례 (경로), [gone] 은 지운 것 (그 안의 것도 지운 것으로 본다), [focused] 는 지금 줄. +/// - 지금 줄이 남아 있으면 그대로 (null = 바꾸지 않음) +/// - 지워졌으면 지운 줄의 다음 줄 가운데 남은 첫째로, 다음이 없으면 (마지막이었으면) 앞 줄 가운데 남은 마지막으로 +/// - 남은 줄이 하나도 없으면 '' (초점 없음) +/// 경로 비교는 [same] (같은 경로인지) · [inside] (안에 있는지 - 같아도 참) 로 받는다 (Windows 대소문자 · dav:// 를 부르는 쪽이 맞춤) +library; + +String? focusAfterDelete( + List shown, + Iterable gone, + String? focused, { + required bool Function(String a, String b) same, + required bool Function(String path, String dir) inside, +}) { + if (focused == null) return null; + bool removed(String x) => gone.any((g) => inside(x, g)); + if (!removed(focused)) return null; + var at = shown.indexWhere((x) => same(x, focused)); + if (at < 0) { + // 지금 줄이 지운 폴더 안에 있었다 - 그 폴더 줄의 자리에서 + at = shown.indexWhere((x) => inside(focused, x) && removed(x)); + } + if (at < 0) return null; + for (var i = at + 1; i < shown.length; i++) { + if (!removed(shown[i])) return shown[i]; + } + for (var i = at - 1; i >= 0; i--) { + if (!removed(shown[i])) return shown[i]; + } + return ''; +} diff --git a/lib/ui/explorer/delete_flow.dart b/lib/ui/explorer/delete_flow.dart index 84fe8d1..b4cf5ea 100644 --- a/lib/ui/explorer/delete_flow.dart +++ b/lib/ui/explorer/delete_flow.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import '../../app/delete_ops.dart'; import '../../core/app_trash.dart'; +import '../../core/explorer/focus_after_delete.dart'; import '../../core/file_ops.dart'; import '../../core/vfs.dart'; import '../../l10n/tr.dart'; @@ -120,6 +121,9 @@ class ExplorerDeleteFlow { ]), ); if (!ok) return; + // 관리자 결정 10/23: 지운 뒤 초점은 지운 줄의 다음 줄로 (마지막이었으면 앞 줄로) - 지우기 전의 차례를 적어 둔다 + final focusedBefore = pane.focused; + final shownBefore = focusedBefore == null ? const [] : [for (final e in sel.shown(pane, vDirname(focusedBefore))) e.path]; // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b) final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null); final failed = report.failed, tooLong = report.tooLong, sent = report.sent, trashed = report.trashed; @@ -228,6 +232,14 @@ class ExplorerDeleteFlow { if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first); } await ctl.refreshAll(paths.map(p.dirname)); + final gone = [for (final x in paths) if (!await vExists(x)) x]; + final next = focusAfterDelete(shownBefore, gone, focusedBefore, same: samePath, inside: isSameOrInside); + // 그사이 사용자가 다른 줄로 옮겼으면 그대로 둔다 (새로 읽으며 지운 줄의 초점이 비워졌을 수도 있다) + final cur = pane.focused; + if (next != null && (cur == null || samePath(cur, focusedBefore!))) { + pane.focused = next.isEmpty ? null : next; + pane.changed(); + } } /// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제) diff --git a/lib/ui/explorer/row_view.dart b/lib/ui/explorer/row_view.dart index 285ecf4..593df58 100644 --- a/lib/ui/explorer/row_view.dart +++ b/lib/ui/explorer/row_view.dart @@ -92,8 +92,9 @@ class ExplorerRowView extends StatelessWidget { ? '..' : row.isRoot ? (ctl.volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2) + // TC: 확장자 칸이 있으면 이름에서 뺀다. 칸이 없으면 (좁은 줄 · 좁아 숨긴 열) 이름에 붙여 정보를 숨기지 않는다 (관리자 10/23) : look == ExplorerStyle.totalcmd - ? totalCmdName(e) + ? (!e.isDir && (tight || pane.colHide >= 1) ? e.name : totalCmdName(e)) : e.name; final dim = TextStyle(fontSize: look.fontSize - 2, color: JjColors.textDim); final nameStyle = TextStyle( diff --git a/test/explorer_tc_look_test.dart b/test/explorer_tc_look_test.dart index a3ebd37..805636e 100644 --- a/test/explorer_tc_look_test.dart +++ b/test/explorer_tc_look_test.dart @@ -1,7 +1,10 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/ui/explorer/tc_key_bar.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:path/path.dart' as p; import 'support/explorer_clock_keys.dart'; @@ -56,7 +59,7 @@ void main() { t.view.devicePixelRatio = 1; addTearDown(t.view.reset); await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(c: c, clock: () => testNow, device: device)))); - final name = style == 'totalcmd' ? 'doc' : 'doc.txt'; + const name = 'doc.txt'; // TC 도 좁은 줄은 확장자 칸이 없어 이름에 붙인다 (관리자 10/23) await settle(t, () => find.text(name).evaluate().isNotEmpty); // X-plore (줄 52) 는 크기 · 날짜가 이름 아래 한 줄, TC (줄 26) 는 두 줄이 들어가지 않아 이름만 expect(find.textContaining('5B · '), style == 'xplore' ? findsWidgets : findsNothing, reason: '$style: 이름 아래 크기 · 날짜'); @@ -74,4 +77,25 @@ void main() { await t.pumpWidget(const SizedBox()); } }); + + testWidgets('지운 뒤 초점: 지운 줄의 다음 줄로 (TC 목록 · F8)', (t) async { + File(p.join(left, 'e.txt')).writeAsStringSync('e'); + c.settings.explorer.style = 'totalcmd'; + await open(t); + await settle(t, () => find.text('[sub]').evaluate().isNotEmpty); + await act(t, () => t.tap(find.text('doc').first)); + await settle(t, () => false, rounds: 15); + // 아래 단추 줄의 [F8 삭제] (키 F8 과 같은 일) → [휴지통으로] + await act(t, () => t.tap(find.byTooltip('F8 삭제'))); + await calm(t); + expect(find.text('휴지통으로 보내기'), findsOneWidget); + await act(t, () => t.tap(find.widgetWithText(FilledButton, '휴지통으로'))); + await settle(t, () => !File(p.join(left, 'doc.txt')).existsSync() && find.byType(SnackBar).evaluate().isNotEmpty); + await calm(t); + expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse); + // 차례: [sub] · doc · e - doc 을 지우면 다음 줄 e 가 지금 줄 + expect(find.text('doc'), findsNothing); + final focusedRow = find.byWidgetPredicate((w) => w is Material && w.shape is Border); + expect(find.descendant(of: focusedRow, matching: find.text('e')), findsOneWidget, reason: '지운 줄의 다음 줄 (e.txt) 이 지금 줄 (왼쪽 강조 막대)'); + }); } diff --git a/test/explorer_text_scale_test.dart b/test/explorer_text_scale_test.dart index 810f598..0fd6680 100644 --- a/test/explorer_text_scale_test.dart +++ b/test/explorer_text_scale_test.dart @@ -93,7 +93,8 @@ void main() { // 시험 글꼴 (Ahem) 은 글자마다 글자 크기만큼 넓어 실제보다 훨씬 길다 - 이름 칸 폭으로 본다 (실제 글꼴은 캡처 시험) expect(para.constraints.maxWidth, greaterThanOrEqualTo(20), reason: '트리의 폴더 이름 칸이 남는다 (예전 412 폭은 0, 실제 글꼴로 360 폭에서 한 줄 5글자쯤)'); final treeRight = tester.getTopRight(tree).dx; - final listLeft = tester.getTopLeft(find.text(style == 'totalcmd' ? 'a' : 'a.txt')).dx; + // 관리자 결정 10/23 (E6 ③): 좁아 TC 의 확장자 칸이 없으면 이름에 확장자를 붙인다 - 이 폭에서는 TC 도 'a.txt' + final listLeft = tester.getTopLeft(find.text('a.txt')).dx; expect(listLeft, greaterThan(treeRight), reason: '트리는 왼쪽, 목록은 오른쪽'); // 트리 줄에는 ✔ 단추가 없다 (오른쪽 목록 줄에만) - X-plore 는 늘 ✔ if (style == 'xplore') { @@ -115,7 +116,8 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final sub = style == 'totalcmd' ? '[sub]' : 'sub'; - final a = style == 'totalcmd' ? 'a' : 'a.txt'; + // 관리자 결정 10/23 (E6 ③): 좁아 TC 의 확장자 칸이 없으면 이름에 확장자를 붙인다 - 이 폭에서는 TC 도 'a.txt' + const a = 'a.txt'; final c = await open(tester, style, sub, device: 1.3, scale: 150, spacing: 'wide'); await tester.pump(); expect(tester.takeException(), isNull); @@ -182,7 +184,8 @@ void main() { }); for (final style in ['xplore', 'windows', 'totalcmd']) { - final a = style == 'totalcmd' ? 'a' : 'a.txt'; + // 관리자 결정 10/23 (E6 ③): 좁아 TC 의 확장자 칸이 없으면 이름에 확장자를 붙인다 - 이 폭에서는 TC 도 'a.txt' + const a = 'a.txt'; testWidgets('40: $style - 처음 값 (100% · 보통) 은 지금 그대로 · 150% · 넓게는 글자 1.5배 · 줄 높이 늘어남 · 412 폭에서 넘치지 않음', (tester) async { tester.view.physicalSize = const Size(412, 900); tester.view.devicePixelRatio = 1; diff --git a/test/focus_after_delete_contract_test.dart b/test/focus_after_delete_contract_test.dart new file mode 100644 index 0000000..9b75b5a --- /dev/null +++ b/test/focus_after_delete_contract_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/explorer/focus_after_delete.dart'; + +/// 약속 (관리자 결정 10/23): 지운 뒤 초점은 지운 줄의 다음 줄로, 마지막이었으면 앞 줄로 (TC · X-plore 와 같음) +void main() { + bool same(String a, String b) => a == b; + bool inside(String x, String d) => x == d || x.startsWith('$d/'); + String? f(List shown, List gone, String? focused) => + focusAfterDelete(shown, gone, focused, same: same, inside: inside); + const list = ['/d/a', '/d/b', '/d/c', '/d/e']; + + test('지운 줄의 다음 줄 · 마지막이면 앞 줄', () { + expect(f(list, ['/d/b'], '/d/b'), '/d/c'); + expect(f(list, ['/d/e'], '/d/e'), '/d/c'); + expect(f(list, ['/d/a'], '/d/a'), '/d/b'); + }); + + test('여럿을 지우면 남은 줄 가운데 다음 첫째 (없으면 앞의 마지막) · 다 지우면 빈 값', () { + expect(f(list, ['/d/b', '/d/c'], '/d/b'), '/d/e'); + expect(f(list, ['/d/c', '/d/e'], '/d/e'), '/d/b'); + expect(f(list, list, '/d/a'), ''); + }); + + test('지금 줄이 남아 있거나 없으면 그대로 (null)', () { + expect(f(list, ['/d/b'], '/d/a'), isNull); + expect(f(list, ['/d/b'], null), isNull); + }); + + test('지금 줄이 지운 폴더 안에 있었으면 그 폴더 줄의 다음', () { + expect(f(list, ['/d/b'], '/d/b/inner.txt'), '/d/c'); + }); +}