E6 보충 (관리자 결정 10/23): 지운 뒤 초점 · 좁은 TC 줄의 확장자 · TC 전용 점검 단계

- 지운 뒤 초점: 지운 줄의 다음 줄로, 마지막이었으면 앞 줄로 (TC · X-plore 같음) - focus_after_delete (순수 함수, 약속 시험 4) +
  지우기 흐름이 부름. 실제 화면 시험: TC 목록에서 F8 단추로 지우면 다음 줄이 지금 줄
- TC: 확장자 칸이 없으면 (좁은 줄 · 좁아 숨긴 열) 이름에 확장자를 붙인다 ('notes.txt') - 정보를 숨기지 않게.
  기존 큰 글자 시험 3곳의 TC 기대를 'a' → 'a.txt' 로 (이 결정에 따른 것, 주석에 적음)
- 탐색기 점검 시험: TC 전용 단계 (Backspace 로 맨 위 · 메뉴 [열기] 로 들어가기 · Backspace 로 나오기 · F5 · F6 · F8 · 지운 뒤 초점,
  단계마다 폴더 시각 다시 고정 + Ctrl+R). 두 번 누르기는 이 실제 시간 시험에서 열기로 잡히지 않아 단위 약속 시험이 본다
- 확인: 단위 1461 ×2 · 통합 6/6 ×2 · 캡처 201장 2회 0px 같음 (base9) · X-plore 는 '삭제 확인' 3장만 바뀜 (초점)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 10:30:21 +09:00
parent ae3365bff1
commit afe15620bd
7 changed files with 184 additions and 6 deletions

View File

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

View File

@ -0,0 +1,32 @@
/// 약속 (관리자 결정 10/23, Total Commander · X-plore 와 같음): 지운 뒤 지금 줄 (초점) 을 어디로 둘지.
/// [shown] 은 지우기 전 그 폴더에 보이던 차례 (경로), [gone] 은 지운 것 (그 안의 것도 지운 것으로 본다), [focused] 는 지금 줄.
/// - 지금 줄이 남아 있으면 그대로 (null = 바꾸지 않음)
/// - 지워졌으면 지운 줄의 다음 줄 가운데 남은 첫째로, 다음이 없으면 (마지막이었으면) 앞 줄 가운데 남은 마지막으로
/// - 남은 줄이 하나도 없으면 '' (초점 없음)
/// 경로 비교는 [same] (같은 경로인지) · [inside] (안에 있는지 - 같아도 참) 로 받는다 (Windows 대소문자 · dav:// 를 부르는 쪽이 맞춤)
library;
String? focusAfterDelete(
List<String> shown,
Iterable<String> 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 '';
}

View File

@ -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 <String>[] : [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 - 바로 영구 삭제)

View File

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

View File

@ -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) 이 지금 줄 (왼쪽 강조 막대)');
});
}

View File

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

View File

@ -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<String> shown, List<String> 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');
});
}