From 4f10cf167f86c5ed21e5b05887e1ae717572c86b Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:27:02 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=83=90=EC=83=89?= =?UTF-8?q?=EA=B8=B0=20=EC=B0=BD=EB=A7=88=EB=8B=A4=20=EB=92=A4=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EC=95=9E=EC=9C=BC=EB=A1=9C=20(75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 창마다 가 본 폴더 내역 (50개). 머리 줄 [←] [→], Alt+← / Alt+→, 마우스 4 · 5번 버튼 - 두 창일 때 마우스 4 · 5번은 마우스가 올라간 창에 적용하고 그 창을 활성으로 (창 밖이면 활성 창) - 갈 폴더가 지워졌거나 열 수 없으면 그 항목은 버리고 지금 폴더는 그대로, 이유를 알림 - 시험: 버튼 · 단축키 · 마우스, 두 창 + 마우스 버튼, 지워진 폴더로 앞으로 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 3 ++ assets/l10n/ja.json | 3 ++ assets/l10n/zh-Hans.json | 3 ++ lib/ui/explorer_page.dart | 103 +++++++++++++++++++++++++++++++++++--- test/explorer_test.dart | 87 ++++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 8 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 99acbbd..61c60a5 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -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", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 85469f7..fc88d3b 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -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 が終わってから行ってください。", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 742efda..a4ef776 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -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 结束后再运行。", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放", diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 7ecc02b..941d964 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -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 = []; + final forward = []; + bool traveling = false; String? focused; final expanded = {}; final cache = >{}; @@ -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 { }); } + /// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) + Future _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 { }, 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 { ), ), ), + ), ), ); } @@ -2746,13 +2803,29 @@ class _ExplorerPageState extends State { }); } + /// 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 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 { ), child: Column(children: children), ), + ), ); } @@ -2911,6 +2985,19 @@ class _ExplorerPageState extends State { 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('즐겨찾기'), diff --git a/test/explorer_test.dart b/test/explorer_test.dart index fc52dff..0d0eab3 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -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 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(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);