파일 탐색기 창마다 뒤로 · 앞으로 (75)
- 창마다 가 본 폴더 내역 (50개). 머리 줄 [←] [→], Alt+← / Alt+→, 마우스 4 · 5번 버튼 - 두 창일 때 마우스 4 · 5번은 마우스가 올라간 창에 적용하고 그 창을 활성으로 (창 밖이면 활성 창) - 갈 폴더가 지워졌거나 열 수 없으면 그 항목은 버리고 지금 폴더는 그대로, 이유를 알림 - 시험: 버튼 · 단축키 · 마우스, 두 창 + 마우스 버튼, 지워진 폴더로 앞으로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>fix_mkv
parent
a7ef14382c
commit
4f10cf167f
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 が終わってから行ってください。",
|
||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
|
||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
|
||||
|
|
|
|||
|
|
@ -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 结束后再运行。",
|
||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
|
||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
|
||||
|
|
|
|||
|
|
@ -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('즐겨찾기'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue