파일 탐색기 창마다 뒤로 · 앞으로 (75)

- 창마다 가 본 폴더 내역 (50개). 머리 줄 [←] [→], Alt+← / Alt+→, 마우스 4 · 5번 버튼
- 두 창일 때 마우스 4 · 5번은 마우스가 올라간 창에 적용하고 그 창을 활성으로 (창 밖이면 활성 창)
- 갈 폴더가 지워졌거나 열 수 없으면 그 항목은 버리고 지금 폴더는 그대로, 이유를 알림
- 시험: 버튼 · 단축키 · 마우스, 두 창 + 마우스 버튼, 지워진 폴더로 앞으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-10 05:27:02 +09:00
parent a7ef14382c
commit 4f10cf167f
5 changed files with 191 additions and 8 deletions

View File

@ -346,6 +346,7 @@
"{0} 추가": "Add {0}",
"{0} 파일": "{0} file",
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)",
"{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "Folder {0} is missing or can't be opened, so it was removed from history",
"{0}. {1} (대기)": "{0}. {1} (waiting)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}",
"{0}: 이미 이동 폴더에 있습니다": "{0}: already in the move folder",
@ -753,6 +754,7 @@
"두 창으로": "Two panes",
"두 창으로 보기 (좌우)": "Show two panes (side by side)",
"뒤로": "Back",
"뒤로 (Alt+←)": "Back (Alt+←)",
"뒤로 보내고 계속": "Keep running in background",
"뒤에서 계속": "Continue in background",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted",
@ -1192,6 +1194,7 @@
"압축 파일 안에 없습니다": "Not in the archive",
"압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.",
"압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}",
"앞으로 (Alt+→)": "Forward (Alt+→)",
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again",

View File

@ -346,6 +346,7 @@
"{0} 추가": "{0} を追加",
"{0} 파일": "{0} ファイル",
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)",
"{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "{0} フォルダーが存在しないか開けないため、履歴から削除しました",
"{0}. {1} (대기)": "{0}. {1}(待機)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します",
"{0}: 이미 이동 폴더에 있습니다": "{0}: 既に移動フォルダーにあります",
@ -753,6 +754,7 @@
"두 창으로": "2 画面に",
"두 창으로 보기 (좌우)": "2 画面で表示 (左右)",
"뒤로": "戻る",
"뒤로 (Alt+←)": "戻る (Alt+←)",
"뒤로 보내고 계속": "裏で続ける",
"뒤에서 계속": "バックグラウンドで続ける",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
@ -1192,6 +1194,7 @@
"압축 파일 안에 없습니다": "圧縮ファイル内にありません",
"압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。",
"압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}",
"앞으로 (Alt+→)": "進む (Alt+→)",
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",

View File

@ -346,6 +346,7 @@
"{0} 추가": "添加 {0}",
"{0} 파일": "{0} 文件",
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)",
"{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "文件夹 {0} 不存在或无法打开,已从历史记录中移除",
"{0}. {1} (대기)": "{0}. {1}(等待)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}",
"{0}: 이미 이동 폴더에 있습니다": "{0}: 已在移动文件夹中",
@ -753,6 +754,7 @@
"두 창으로": "双窗格",
"두 창으로 보기 (좌우)": "以双窗格显示 (左右)",
"뒤로": "后退",
"뒤로 (Alt+←)": "后退 (Alt+←)",
"뒤로 보내고 계속": "转到后台继续",
"뒤에서 계속": "在后台继续",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
@ -1192,6 +1194,7 @@
"압축 파일 안에 없습니다": "压缩文件中没有",
"압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。",
"압축을 풀 수 없습니다: {0}": "无法解压: {0}",
"앞으로 (Alt+→)": "前进 (Alt+→)",
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",

View File

@ -4,6 +4,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
@ -166,7 +167,24 @@ enum ExplorerButton {
/// 한 창의 상태: 저장 장치 (맨 위), 펼친 폴더, 읽어 둔 목록, 표시한 항목
class _Pane extends ChangeNotifier {
String root;
String current;
/// 지금 폴더. 75: 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고)
String get current => _current;
set current(String v) {
if (!traveling && _current.isNotEmpty && v.isNotEmpty && !samePath(_current, v)) {
back.add(_current);
if (back.length > 50) back.removeAt(0);
forward.clear();
}
_current = v;
}
String _current;
/// 75: 창마다 뒤로 · 앞으로 (Alt+← / →, 마우스 4 · 5번 버튼)
final back = <String>[];
final forward = <String>[];
bool traveling = false;
String? focused;
final expanded = <String>{};
final cache = <String, List<FileEntry>>{};
@ -183,7 +201,7 @@ class _Pane extends ChangeNotifier {
/// "폴더 + 파일 목록" 배치의 오른쪽 파일 목록 스크롤
final listScroll = ScrollController();
_Pane(this.root) : current = root;
_Pane(this.root) : _current = root;
/// 화면을 닫은 뒤 끝난 파일 작업 · 읽기가 알리지 않게
bool _disposed = false;
@ -713,6 +731,35 @@ class _ExplorerPageState extends State<ExplorerPage> {
});
}
/// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로)
Future<void> _travel(_Pane pane, {required bool forward}) async {
final from = forward ? pane.forward : pane.back;
if (from.isEmpty) return;
final to = from.removeLast();
setState(() => _active = _panes.indexOf(pane));
// 지워졌거나 열 수 없는 폴더: 그 항목은 버리고 지금 폴더는 그대로 (이유를 알림)
bool ok;
try {
ok = await vIsDir(to);
} catch (_) {
ok = false;
}
if (!mounted) return;
if (!ok) {
setState(() {});
_snack(trf('{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다', [_displayPath(to)]));
return;
}
(forward ? pane.back : pane.forward).add(pane.current);
pane.traveling = true;
try {
await _goTo(pane, to);
} finally {
pane.traveling = false;
}
if (mounted) setState(() {});
}
/// 마지막 폴더 · 내역 저장
void _remember(_Pane pane, String dir) {
final i = _panes.indexOf(pane);
@ -2624,12 +2671,21 @@ class _ExplorerPageState extends State<ExplorerPage> {
},
const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename),
const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up),
// 75: 지금 창의 뒤로 · 앞으로
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _travel(_panes[_active], forward: false),
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _travel(_panes[_active], forward: true),
const SingleActivator(LogicalKeyboardKey.tab): () =>
setState(() => _active = _dual ? 1 - _active : 0),
},
child: Focus(
autofocus: true,
child: Scaffold(
child: Listener(
// 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [_frame])
onPointerDown: (ev) {
if (identical(ev, _navEvent)) return;
_mouseTravel(_active, ev);
},
child: Scaffold(
body: SwipeNav(
current: widget.rsync ? 'rsync' : 'explorer',
child: ListenableBuilder(
@ -2650,6 +2706,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
),
),
),
),
),
);
}
@ -2746,13 +2803,29 @@ class _ExplorerPageState extends State<ExplorerPage> {
});
}
/// 75: 마우스 4 · 5번 - 그 창의 뒤로 · 앞으로 (그 창을 활성으로)
PointerDownEvent? _navEvent;
void _mouseTravel(int i, PointerDownEvent ev) {
if (i >= _panes.length) return;
if (ev.buttons & kBackMouseButton != 0) {
_navEvent = ev;
_travel(_panes[i], forward: false);
} else if (ev.buttons & kForwardMouseButton != 0) {
_navEvent = ev;
_travel(_panes[i], forward: true);
}
}
Widget _frame(int i, List<Widget> children) {
final active = i == _active;
return GestureDetector(
onTapDown: (_) {
if (_active != i) setState(() => _active = i);
},
child: Container(
return Listener(
// 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도)
onPointerDown: (ev) => _mouseTravel(i, ev),
child: GestureDetector(
onTapDown: (_) {
if (_active != i) setState(() => _active = i);
},
child: Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: JjColors.bg,
@ -2761,6 +2834,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
),
child: Column(children: children),
),
),
);
}
@ -2911,6 +2985,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
children: [
// 75: 이 창의 뒤로 · 앞으로 (가 본 폴더)
IconButton(
tooltip: tr('뒤로 (Alt+←)'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_back, size: 16),
onPressed: pane.back.isEmpty ? null : () => _travel(pane, forward: false),
),
IconButton(
tooltip: tr('앞으로 (Alt+→)'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_forward, size: 16),
onPressed: pane.forward.isEmpty ? null : () => _travel(pane, forward: true),
),
// 73: 즐겨찾기 (창 위에서 한 번에 - 드라이브가 많아도 보이게 맨 앞)
IconButton(
tooltip: tr('즐겨찾기'),

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -377,6 +378,92 @@ void main() {
expect(find.textContaining('3개 표시함'), findsOneWidget, reason: 'b.srt · c.mkv · doc.txt');
});
testWidgets('75: 창마다 뒤로 · 앞으로 - 버튼 · Alt+← / → · 마우스 4 · 5번', (tester) async {
await open(tester);
final sub = p.join(left, 'sub');
String now() => c.settings.explorerPaths.first;
await act(tester, () => tester.tap(find.text('sub').first));
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
// 뒤로 (버튼): 왼쪽 창은 left 로
await act(tester, () => tester.tap(find.byTooltip('뒤로 (Alt+←)').first));
await settle(tester, () => now() == left, rounds: 50);
expect(now(), left);
// 앞으로 (버튼)
await act(tester, () => tester.tap(find.byTooltip('앞으로 (Alt+→)').first));
await settle(tester, () => now() == sub, rounds: 50);
expect(now(), sub);
// Alt+← · Alt+→
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
await settle(tester, () => now() == left, rounds: 50);
expect(now(), left);
// 마우스 5번 (앞으로) · 4번 (뒤로)
Future<void> mouse(int button) async {
final g = await tester.createGesture(kind: PointerDeviceKind.mouse, buttons: button);
await g.addPointer(location: tester.getCenter(find.text('doc.txt').first));
await g.down(tester.getCenter(find.text('doc.txt').first));
await g.up();
await g.removePointer();
}
await mouse(kForwardMouseButton);
await settle(tester, () => now() == sub, rounds: 50);
expect(now(), sub);
await mouse(kBackMouseButton);
await settle(tester, () => now() == left, rounds: 50);
expect(now(), left);
});
testWidgets('75: 두 창 - 마우스 4 · 5번은 마우스가 올라간 창에 (그 창이 활성으로)', (tester) async {
File(p.join(right, 'r1', 'x.txt'))
..createSync(recursive: true)
..writeAsStringSync('x');
await open(tester);
final r1 = p.join(right, 'r1');
final sub = p.join(left, 'sub');
String at(int i) => c.settings.explorerPaths[i];
await act(tester, () => tester.tap(find.text('r1')));
await settle(tester, () => at(1) == r1 && find.text('x.txt').evaluate().isNotEmpty, rounds: 50);
// 왼쪽 창이 활성
await act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => at(0) == sub, rounds: 50);
// 오른쪽 창 위에서 4번 (뒤로) - 오른쪽만 뒤로
final g = await tester.createGesture(kind: PointerDeviceKind.mouse, buttons: kBackMouseButton);
await g.addPointer(location: tester.getCenter(find.text('x.txt')));
await g.down(tester.getCenter(find.text('x.txt')));
await g.up();
await g.removePointer();
await settle(tester, () => at(1) == right, rounds: 50);
expect([at(0), at(1)], [sub, right]);
// 오른쪽 창이 활성이 되었으므로 Alt+→ 도 오른쪽
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
await settle(tester, () => at(1) == r1, rounds: 50);
expect([at(0), at(1)], [sub, r1]);
});
testWidgets('75: 지워진 폴더로 앞으로 - 그 항목은 버리고 지금 폴더 그대로 · 이유를 알림', (tester) async {
await open(tester);
final sub = p.join(left, 'sub');
String now() => c.settings.explorerPaths.first;
await act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => now() == sub, rounds: 50);
await act(tester, () => tester.tap(find.byTooltip('뒤로 (Alt+←)').first));
await settle(tester, () => now() == left, rounds: 50);
await tester.runAsync(() => Directory(sub).delete(recursive: true));
await act(tester, () => tester.tap(find.byTooltip('앞으로 (Alt+→)').first));
await settle(tester, () => find.textContaining('내역에서 뺐습니다').evaluate().isNotEmpty, rounds: 50);
expect(find.textContaining('내역에서 뺐습니다'), findsOneWidget);
expect(now(), left);
// 버린 항목은 다시 가지 않는다 (앞으로 버튼이 꺼짐)
final fwd = tester.widget<IconButton>(find.ancestor(of: find.byTooltip('앞으로 (Alt+→)').first, matching: find.byType(IconButton)).first);
expect(fwd.onPressed, isNull);
ScaffoldMessenger.of(tester.element(find.byType(Scaffold).first)).clearSnackBars();
await tester.pump();
});
test('74: 패턴 (* · ? · 여러 개 · 대소문자 무시)', () {
final re = globToRegExp('*.srt; ep??.mkv');
expect(['a.srt', 'A.SRT', 'ep01.mkv'].every(re.hasMatch), isTrue);