diff --git a/assets/l10n/en.json b/assets/l10n/en.json index c8d29be..5df7d33 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -427,8 +427,6 @@ "X-plore 스타일: 옆으로 밀면 창 바꾸기": "X-plore style: swipe sideways to switch panes", "X-plore 스타일: 표시 동그라미 늘 보이기": "X-plore style: always show the mark circles", "X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)": "In the X-plore style they appear in one tree like X-plore (the tabs and ★ at the top stay)", - "X-plore 식 (1 · 2 · 3번)": "X-plore way (1st · 2nd · 3rd)", - "X-plore 식: 1번 = 폴더, 2번 = 안의 파일 전체, 3번 = 풀기": "X-plore way: 1st = the folder, 2nd = all files inside, 3rd = clear", "X-plore 처럼. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)": "Like X-plore. When off, folders collapse and expand only with the › arrow (the list doesn't move on a double tap)", "YouTube · 토렌트 다운로드 목록": "YouTube · torrent download list", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp, magnet · .torrent → aria2 (no seeding after completion)", @@ -2966,9 +2964,7 @@ "폴더를 자기 안으로 복사할 수 없습니다": "Cannot copy a folder into itself", "폴더를 자기 안으로 옮길 수 없습니다": "Cannot move a folder into itself", "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "Cannot {0} a folder into itself: {1}", - "폴더를 표시하면": "When marking a folder", "폴더만 lsync 로 옮길 수 있습니다.": "Only folders can be moved to lsync.", - "폴더와 안의 것 전체 (기본)": "The folder and everything inside (default)", "폴란드어": "Polish", "표시": "Mark", "표시 (여러 개 고르기)": "Mark (select several)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 321e1a0..262c96d 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -427,8 +427,6 @@ "X-plore 스타일: 옆으로 밀면 창 바꾸기": "X-plore スタイル: 横にスワイプでペイン切り替え", "X-plore 스타일: 표시 동그라미 늘 보이기": "X-plore スタイル: 選択の丸を常に表示", "X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)": "X-plore スタイルで X-plore のように 1 つのツリーに表示します (上部のタブ・★ はそのまま)", - "X-plore 식 (1 · 2 · 3번)": "X-plore 式 (1・2・3 回目)", - "X-plore 식: 1번 = 폴더, 2번 = 안의 파일 전체, 3번 = 풀기": "X-plore 式: 1 回目 = フォルダー、2 回目 = 中のファイル全部、3 回目 = 解除", "X-plore 처럼. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)": "X-plore のように。オフのときは › 矢印でのみ開閉します (2 回押しても一覧は動きません)", "YouTube · 토렌트 다운로드 목록": "YouTube・トレント ダウンロード一覧", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp、マグネット・.torrent → aria2(完了後のアップロードなし)", @@ -2966,9 +2964,7 @@ "폴더를 자기 안으로 복사할 수 없습니다": "フォルダーを自分自身の中にコピーできません", "폴더를 자기 안으로 옮길 수 없습니다": "フォルダーを自分自身の中に移動できません", "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "フォルダーを自分自身の中へ{0}ことはできません: {1}", - "폴더를 표시하면": "フォルダーにチェックしたとき", "폴더만 lsync 로 옮길 수 있습니다.": "lsync へ移せるのはフォルダーだけです。", - "폴더와 안의 것 전체 (기본)": "フォルダーと中身すべて (既定)", "폴란드어": "ポーランド語", "표시": "選択", "표시 (여러 개 고르기)": "マーク (複数選択)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index eb8face..a2ddf12 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -427,8 +427,6 @@ "X-plore 스타일: 옆으로 밀면 창 바꾸기": "X-plore 样式: 左右滑动切换窗格", "X-plore 스타일: 표시 동그라미 늘 보이기": "X-plore 样式: 始终显示标记圆圈", "X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)": "在 X-plore 样式中像 X-plore 一样显示在一棵树中 (顶部的标签 · ★ 保持不变)", - "X-plore 식 (1 · 2 · 3번)": "X-plore 方式 (第 1 · 2 · 3 次)", - "X-plore 식: 1번 = 폴더, 2번 = 안의 파일 전체, 3번 = 풀기": "X-plore 方式: 第 1 次 = 文件夹,第 2 次 = 其中所有文件,第 3 次 = 取消", "X-plore 처럼. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)": "像 X-plore 一样。关闭时仅用 › 箭头折叠 · 展开 (双击时列表不会移动)", "YouTube · 토렌트 다운로드 목록": "YouTube · 种子下载列表", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp,磁力链接 · .torrent → aria2(完成后不做种)", @@ -2966,9 +2964,7 @@ "폴더를 자기 안으로 복사할 수 없습니다": "不能将文件夹复制到其自身内部", "폴더를 자기 안으로 옮길 수 없습니다": "不能将文件夹移动到其自身内部", "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "不能将文件夹{0}到其自身内部: {1}", - "폴더를 표시하면": "标记文件夹时", "폴더만 lsync 로 옮길 수 있습니다.": "只有文件夹可以移到 lsync。", - "폴더와 안의 것 전체 (기본)": "文件夹及其所有内容 (默认)", "폴란드어": "波兰语", "표시": "标记", "표시 (여러 개 고르기)": "标记 (多选)", diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 0f4e4b9..ebe45be 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -441,9 +441,6 @@ class AppSettings { /// X-plore 비교 ⚖ (관리자 결정 10/18): X-plore 스타일에서 줄 끝 표시 동그라미를 늘 보이기 (처음 값 켬). 다른 스타일은 선택 모드에서만 (10/5) bool explorerAlwaysMarks = true; - /// X-plore 비교 ⚖: 폴더 표시 규칙 - 'tree' 자신 + 안의 것 전체 (지금, 처음 값 - 고르는 범위가 바뀌면 지우기 · 이동 대상이 바뀌어 그대로) / - /// 'xplore' 1번 = 폴더 자신, 2번 = 안의 파일 전체, 3번 = 풀기 - String explorerFolderMarkRule = 'tree'; /// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (설정으로만, 처음 값 끔 - 10/8 사용자 결정 "화면 가운데를 밀면 화면 이동" 을 지킴, 관리자 판단 10/18) bool explorerSwipePanes = false; @@ -883,7 +880,6 @@ class AppSettings { 'batchRenameLast': batchRenameLast, 'explorerRootsInTree': explorerRootsInTree, 'explorerAlwaysMarks': explorerAlwaysMarks, - 'explorerFolderMarkRule': explorerFolderMarkRule, 'explorerSwipePanes': explorerSwipePanes, 'androidTrashDays': androidTrashDays, 'explorerClick': explorerClick, @@ -1129,7 +1125,6 @@ class AppSettings { ..batchRenameLast = j['batchRenameLast'] is Map ? Map.from(j['batchRenameLast'] as Map) : {} ..explorerRootsInTree = j['explorerRootsInTree'] != false ..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false - ..explorerFolderMarkRule = j['explorerFolderMarkRule'] == 'xplore' ? 'xplore' : 'tree' ..explorerSwipePanes = j['explorerSwipePanes'] == true ..androidTrashDays = switch (j['androidTrashDays']) { final int d when d >= 0 && d <= 365 => d, _ => 14 } ..explorerLayout = const ['single', 'split', 'auto'].contains(j['explorerLayout']) diff --git a/lib/core/selection.dart b/lib/core/selection.dart index feb2d80..d438fac 100644 --- a/lib/core/selection.dart +++ b/lib/core/selection.dart @@ -8,7 +8,7 @@ import 'vfs.dart' show vDirname; /// 약속 /// - [ClickPolicy.decide]: 누르기 (한 번 · 두 번 · 길게 · 오른쪽) · 항목 종류 · 선택 모드 · 펼침 → 할 일 하나 ([ClickAction]). /// 모르는 조합은 [ClickAction.none] (아무것도 하지 않음). 화면 · 기기 · 시각을 모른다 (같은 입력 = 같은 결과). -/// - [Selection]: 창 하나의 표시 (고른 경로 집합). +/// - [Selection]: 창 하나의 표시 (고른 경로 집합). 이름 바꾸기 · 지우기를 따라간다 ([rename] · [forget] · [removeWhere]). /// · 파일: 누르면 고름, 다시 누르면 취소 (규칙 5) /// · 폴더: 누르면 자기 + 안의 것 전체, 다시 누르면 자기와 안의 것 전체 취소 (규칙 6) /// · 고른 폴더 안의 항목 하나를 누르면 그것만 빠짐 (나머지는 고른 채) @@ -62,23 +62,31 @@ class ClickPolicy { /// [selectMode] 선택 모드 (가운데 [선택]) · [expanded] 그 폴더가 펼쳐져 있는지 · /// [list] 트리가 아니라 목록 (폴더 + 목록 배치의 오른쪽) 의 줄인지 · /// [repeat] 같은 줄을 두 번 누르기 시간 안에 다시 누른 것인지 (폴더는 두 번 누르기를 기다리지 않으므로, - /// 두 번째 누르기로 방금 펼친 것을 접지 않게) + /// 두 번째 누르기로 방금 펼친 것을 접지 않게). + /// 사용자 설정 (처음 값은 규칙대로): + /// [tapOpens] 파일 한 번 누르기 = 바로 실행 (설정 "바로 열기", 이때 두 번 누르기는 받지 않음) · + /// [retapCollapses] 펼친 폴더를 다시 누르면 접기 (끄면 [ClickAction.expand] - 화면은 펼친 폴더를 지금 폴더로만) static ClickAction decide(Press press, ItemKind kind, - {required bool selectMode, bool expanded = false, bool list = false, bool repeat = false}) { + {required bool selectMode, + bool expanded = false, + bool list = false, + bool repeat = false, + bool tapOpens = false, + bool retapCollapses = true}) { final real = kind == ItemKind.file || kind == ItemKind.folder; switch (press) { case Press.longPress: case Press.secondary: return real ? ClickAction.menu : ClickAction.none; case Press.doubleTap: - // 두 번 = 실행 (파일만, 선택 모드 밖). 폴더 · 선택 모드는 한 번 누르기로 이미 처리됨 - return kind == ItemKind.file && !selectMode ? ClickAction.open : ClickAction.none; + // 두 번 = 실행 (파일만, 선택 모드 밖). 폴더 · 선택 모드 · 바로 열기는 한 번 누르기로 이미 처리됨 + return kind == ItemKind.file && !selectMode && !tapOpens ? ClickAction.open : ClickAction.none; case Press.tap: if (kind == ItemKind.up) return ClickAction.open; if (selectMode && real) return ClickAction.toggleMark; - if (kind == ItemKind.file) return ClickAction.focus; + if (kind == ItemKind.file) return tapOpens ? ClickAction.open : ClickAction.focus; if (list && kind == ItemKind.folder) return ClickAction.open; - if (!expanded) return ClickAction.expand; + if (!expanded || !retapCollapses) return ClickAction.expand; return repeat ? ClickAction.none : ClickAction.collapse; } } @@ -199,6 +207,26 @@ class Selection { _split.removeWhere((s) => isSameOrInside(s, path)); } + /// [test] 에 맞는 표시를 뺀다 (예: 다른 곳에서 지워져 없는 것) + void removeWhere(bool Function(String path) test) { + _marked.removeWhere(test); + _split.removeWhere(test); + } + + /// 이름이 바뀐 것: 표시돼 있었으면 새 이름으로 (안의 표시도 새 경로로) + void rename(String from, String to) { + String moved(String x) => samePath(x, from) ? to : to + x.substring(from.length); + for (final list in [_marked, _split]) { + for (var i = 0; i < list.length; i++) { + if (isSameOrInside(list[i], from)) list[i] = moved(list[i]); + } + } + if (anchor != null && isSameOrInside(anchor!, from)) anchor = moved(anchor!); + } + + /// [path] 자신이 표시돼 있는지 (위 폴더로 함께 골라진 것은 아님) + bool has(String path) => _marked.any((m) => samePath(m, path)); + void clear() { _marked.clear(); _split.clear(); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index c693bfe..ff5069a 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -16,6 +16,7 @@ import '../app/copy_center.dart'; import '../app/live_sync.dart'; import '../app/settings.dart' show CopyTask, FolderBookmark; import '../app/transfer_job.dart'; +import '../core/selection.dart'; import '../core/secret_gate.dart'; import '../core/sync_tools.dart'; import '../core/file_ops.dart'; @@ -293,10 +294,8 @@ class _Pane extends ChangeNotifier { /// l: 처음 보는 (바뀐) FTPS 자체 서명 인증서 - 폴더마다 final certErrors = {}; - final marked = {}; - - /// 74: 범위 고르기의 시작 (마지막으로 표시한 것) - String? anchor; + /// 표시 (고른 것) · 범위 고르기의 시작 - 규칙은 [Selection] 한 곳 (E1) + final Selection sel; /// X-plore 비교 c: 트리의 "즐겨찾기" 묶음을 펼쳤는지 bool favoritesOpen = false; @@ -316,7 +315,9 @@ class _Pane extends ChangeNotifier { /// 202: 창 위 저장 장치 탭 줄 (넘치면 옆으로 - 휠 · 끌기) final tabScroll = ScrollController(); - _Pane(this.root) : _current = root; + _Pane(this.root, {bool rsync = false}) + : _current = root, + sel = Selection(rsync: rsync); /// 화면을 닫은 뒤 끝난 파일 작업 · 읽기가 알리지 않게 bool _disposed = false; @@ -408,7 +409,7 @@ class _ExplorerPageState extends State { // X-plore 비교 5-2: 루트 (su, 읽기 전용) - 환경 설정에서 켰을 때 (Android) if (c.settings.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')), ]; - late final List<_Pane> _panes = [_Pane(''), _Pane('')]; + late final List<_Pane> _panes = [_Pane('', rsync: widget.rsync), _Pane('', rsync: widget.rsync)]; int _active = 0; bool _ready = false; @@ -1005,7 +1006,7 @@ class _ExplorerPageState extends State { if (!_ready) return; for (final pane in _panes) { if (isDav(pane.root) && !DavPath.parse(pane.root).serverKnown) { - pane.marked.clear(); + pane.sel.clear(); pane.cache.removeWhere((k, _) => isDav(k)); pane.expanded.removeWhere(isDav); pane.root = _local.first.$1; // 다시 그릴 때 또 하지 않게 @@ -1593,7 +1594,7 @@ class _ExplorerPageState extends State { _selecting = on; if (!on) { for (final x in _panes) { - x.marked.clear(); + x.sel.clear(); } } }); @@ -1609,57 +1610,96 @@ class _ExplorerPageState extends State { Future _onTap(_Pane pane, _Row row, {bool list = false}) async { final e = row.entry; setState(() => _active = _panes.indexOf(pane)); - if (row.isUp) { - await _goTo(pane, e.path); - return; - } - if (widget.rsync) { + if (widget.rsync && !row.isUp) { await _pick(pane, e); return; } + final real = !row.isRoot && !row.isUp; // 74: Shift + 누르기 = 마지막으로 표시한 것부터 여기까지 (PC) - if (!row.isRoot && HardwareKeyboard.instance.isShiftPressed && _canRange(pane, e)) { + if (real && HardwareKeyboard.instance.isShiftPressed && _canRange(pane, e)) { if (!_selecting) setState(() => _selecting = true); _markRange(pane, e); return; } - if (!row.isRoot && (_selecting || HardwareKeyboard.instance.isControlPressed)) { + // PC: Ctrl + 누르기 = 선택 모드로 고르기 + if (real && HardwareKeyboard.instance.isControlPressed) { if (!_selecting) setState(() => _selecting = true); await _toggleMark(pane, e); return; } - // X-plore 비교 d: 펼쳐 둔 지금 폴더를 다시 누르면 접기 (설정). 두 번 누르기의 두 번째 (짧은 사이) 는 접지 않는다 - if (e.isDir && !list && c.settings.explorerRetapCollapse) { - final now = DateTime.now(); - final last = _lastFolderTap; - _lastFolderTap = (e.path, now); - final again = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2; - if (!again && pane.expanded.contains(e.path) && samePath(pane.current, e.path)) { - await _toggle(pane, e); + // 폴더는 두 번 누르기를 기다리지 않는다 - 짧은 사이의 두 번째 누르기로 방금 펼친 것을 접지 않게 + final now = DateTime.now(); + final last = _lastFolderTap; + _lastFolderTap = (e.path, now); + final repeat = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2; + final act = ClickPolicy.decide(Press.tap, _kindOf(row), + selectMode: _selecting, + expanded: pane.expanded.contains(e.path), + list: list, + repeat: repeat, + tapOpens: !_selectMode, + retapCollapses: c.settings.explorerRetapCollapse); + await _act(pane, row, act, list: list); + } + + /// 줄의 종류 ([ClickPolicy] 에 넘김) + ItemKind _kindOf(_Row row) => row.isUp + ? ItemKind.up + : row.isRoot + ? ItemKind.root + : row.entry.isDir + ? ItemKind.folder + : ItemKind.file; + + /// [ClickPolicy] 가 정한 일을 한다 (화면 쪽 동작만 - 규칙은 [ClickPolicy] · [Selection]) + Future _act(_Pane pane, _Row row, ClickAction act, {bool list = false}) async { + final e = row.entry; + switch (act) { + case ClickAction.none: + case ClickAction.menu: return; - } + case ClickAction.focus: + pane.focused = e.path; + if (!list) pane.current = vDirname(e.path); + pane.changed(); + case ClickAction.open: + await _onOpen(pane, row, list: list); + case ClickAction.collapse: + await _toggle(pane, e); + case ClickAction.expand: + if (!pane.expanded.contains(e.path)) { + await _toggle(pane, e); + } else if (!samePath(pane.current, e.path)) { + // 펼쳐 둔 폴더 (다시 눌러 접기를 끈 설정): 그 폴더를 지금 폴더로 + pane + ..current = e.path + ..focused = e.path + ..changed(); + _remember(pane, e.path); + } + case ClickAction.toggleMark: + if (!_selecting) setState(() => _selecting = true); + await _toggleMark(pane, e); } - if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list); - pane.focused = e.path; - if (!list) pane.current = vDirname(e.path); - pane.changed(); } /// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것). /// Rsync 화면은 고른 그 폴더만. - bool _isMarked(_Pane pane, String path) => - pane.marked.any((m) => widget.rsync ? samePath(path, m) : isSameOrInside(path, m)); + bool _isMarked(_Pane pane, String path) => pane.sel.isMarked(path); - /// Rsync 화면: 창마다 폴더 하나만 고른다 (다시 누르면 취소). 고르면 펼쳐서 안의 폴더가 보이게. + /// [Selection] 이 폴더를 나눌 때 안의 경로 (읽어 둔 목록, 없으면 읽음) + Future> _childPaths(_Pane pane, String dir) async { + await _load(pane, dir); + return [for (final x in pane.cache[dir] ?? const []) x.path]; + } + + /// Rsync 화면: 창마다 폴더 하나만 고른다 (다시 누르면 취소, 규칙 8). 고르면 펼쳐서 안의 폴더가 보이게. Future _pick(_Pane pane, FileEntry e) async { if (!e.isDir) return; - if (pane.marked.length == 1 && samePath(pane.marked.first, e.path)) { - setState(pane.marked.clear); - return; - } - setState(() => pane.marked - ..clear() - ..add(e.path)); + await pane.sel.toggle(e.path, isDir: true, list: (d) => _childPaths(pane, d)); + if (!mounted) return; + setState(() {}); + if (!pane.sel.has(e.path)) return; if (!pane.expanded.contains(e.path)) { await _toggle(pane, e); } else { @@ -1672,7 +1712,7 @@ class _ExplorerPageState extends State { /// 74: 범위 고르기를 할 수 있는지 - 마지막으로 표시한 것이 같은 폴더에 있고 다른 항목 bool _canRange(_Pane pane, FileEntry e) { - final a = pane.anchor; + final a = pane.sel.anchor; return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a); } @@ -1691,110 +1731,41 @@ class _ExplorerPageState extends State { ]; /// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시 - void _markRange(_Pane pane, FileEntry to) { - final list = _shown(pane, vDirname(to.path)); - final a = list.indexWhere((x) => samePath(x.path, pane.anchor!)); - final b = list.indexWhere((x) => samePath(x.path, to.path)); - if (a < 0 || b < 0) return; - setState(() { - for (var i = a < b ? a : b; i <= (a < b ? b : a); i++) { - pane.marked.add(list[i].path); - } - _normalizeMarks(pane); - pane.anchor = to.path; - pane.focused = to.path; - }); - } + void _markRange(_Pane pane, FileEntry to) => setState(() { + pane.sel.range([for (final x in _shown(pane, vDirname(to.path))) x.path], to.path); + pane.focused = to.path; + }); /// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서 - void _invertMarks(_Pane pane, String dir) => setState(() { - for (final x in _shown(pane, dir)) { - if (!pane.marked.remove(x.path)) pane.marked.add(x.path); - } - _normalizeMarks(pane); - }); + void _invertMarks(_Pane pane, String dir) => + setState(() => pane.sel.invert([for (final x in _shown(pane, dir)) x.path])); - void _markWhere(_Pane pane, String dir, bool Function(FileEntry e) test) => setState(() { - pane.marked.addAll([for (final x in _shown(pane, dir)) if (test(x)) x.path]); - _normalizeMarks(pane); - }); + void _markWhere(_Pane pane, String dir, bool Function(FileEntry e) test) => + setState(() => pane.sel.addAll([for (final x in _shown(pane, dir)) if (test(x)) x.path])); Future _markPattern(_Pane pane, String dir) async { final pattern = await showDialog(context: context, builder: (_) => const _PatternDialog()); if (pattern == null || pattern.trim().isEmpty) return; final re = globToRegExp(pattern); - final before = pane.marked.length; + final before = pane.sel.marked.length; _markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path))); - if (pane.marked.length == before) _snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()])); + if (pane.sel.marked.length == before) _snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()])); } - /// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로) - void _normalizeMarks(_Pane pane) { - final all = pane.marked.toList(); - pane.marked.removeWhere((m) => all.any((o) => !identical(o, m) && !samePath(o, m) && isSameOrInside(m, o))); - } - - /// 선택 모드에서 누르기. 폴더는 자기 자신과 안의 파일 · 폴더 전체가 한 묶음이다. - /// - 고르지 않은 것을 누르면 고른다 (폴더는 펼쳐서 안의 것도 모두 고른 모양으로 보여 줌) - /// - 고른 것을 다시 누르면 자기 자신과 안의 것을 모두 푼다. - /// 위 폴더를 골라서 함께 골라진 것이면 그것만 빼고, 같은 폴더의 나머지는 고른 채로 둔다. + /// 선택 모드에서 누르기 (규칙 5 · 6 은 [Selection.toggle]). 고른 폴더는 펼쳐서 안의 것도 고른 모양으로 보여 준다. Future _toggleMark(_Pane pane, FileEntry e) async { pane.focused = e.path; - // X-plore 비교 ⚖ (설정 'xplore'): 폴더 1번 = 폴더 자신 · 2번 = 안의 파일 전체 · 3번 = 풀기 - if (e.isDir && c.settings.explorerFolderMarkRule == 'xplore' && !widget.rsync) { - final self = pane.marked.any((m) => samePath(m, e.path)); - if (self) { - await _load(pane, e.path); - final kids = [for (final x in pane.cache[e.path] ?? const []) if (!x.isDir) x.path]; - setState(() { - pane.marked.removeWhere((m) => samePath(m, e.path)); - pane.marked.addAll(kids); - pane.expanded.add(e.path); - }); - return; - } - final kids = [for (final x in pane.cache[e.path] ?? const []) if (!x.isDir) x.path]; - final inside = kids.isNotEmpty && kids.every((k) => pane.marked.any((m) => samePath(m, k))); - if (inside) { - setState(() => pane.marked.removeWhere((m) => kids.any((k) => samePath(k, m)))); - return; - } + try { + await pane.sel.toggle(e.path, isDir: e.isDir, list: (d) => _childPaths(pane, d)); + } on FileSystemException catch (err) { + _snack(explainFileError('$err', dav: isDav(e.path)).$1); } - if (!_isMarked(pane, e.path)) { - setState(() { - pane.marked.removeWhere((m) => isSameOrInside(m, e.path)); // 안에 따로 고른 것은 이 폴더 하나로 - pane.marked.add(e.path); - pane.anchor = e.path; - }); - if (e.isDir) { - await _load(pane, e.path); - setState(() => pane.expanded.add(e.path)); - } - return; + if (!mounted) return; + setState(() {}); + if (e.isDir && pane.sel.has(e.path) && !pane.expanded.contains(e.path)) { + await _load(pane, e.path); + if (mounted) setState(() => pane.expanded.add(e.path)); } - final owner = pane.marked.firstWhere((m) => isSameOrInside(e.path, m)); - setState(() => pane.marked.removeWhere((m) => isSameOrInside(m, e.path))); - if (samePath(owner, e.path)) return; - // 위 폴더 [owner] 를 골라 둔 상태: owner 표시를 풀고, e 까지 내려가는 길의 다른 항목은 고른 채로 - final keep = []; - var dir = owner; - while (!samePath(dir, e.path)) { - await _load(pane, dir); - String? next; - for (final x in pane.cache[dir] ?? const []) { - if (isSameOrInside(e.path, x.path)) { - next = x.path; - } else { - keep.add(x.path); - } - } - if (next == null) break; - dir = next; - } - setState(() { - pane.marked.remove(owner); - pane.marked.addAll(keep); - }); } /// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기): @@ -2171,14 +2142,14 @@ class _ExplorerPageState extends State { final dav = isDav(e.path); // 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만) final viewer = c.settings.components.contains('viewer'); - final picked = pane.marked.contains(e.path) ? pane.marked.toList() : [e.path]; + final picked = pane.sel.has(e.path) ? pane.sel.marked : [e.path]; final images = [for (final x in picked) if (c.settings.imageExts.contains(extOf(x))) x]; final ext = extOf(e.path); final viewable = viewer && !e.isDir && (c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); final dual = _dual; // 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사) - final targets = _isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path]; + final targets = _isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]; final many = targets.length > 1; // 데이터 검토 (10/18): 펼친 압축 안의 것은 실제 파일이 아니다 - 그것으로 새 파일을 만들거나 내놓는 항목 (ZIP 묶기 · Wi-Fi 공유 · PDF) 은 숨긴다 final inArchive = targets.any(_insideArchive) || _insideArchive(e.path); @@ -2342,7 +2313,7 @@ class _ExplorerPageState extends State { case 'wifiShare': await _wifiShare(e.path); case 'zip': - final items = outermostPaths(_isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path]); + final items = outermostPaths(_isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other); if (made != null && mounted) { @@ -2358,7 +2329,7 @@ class _ExplorerPageState extends State { if (!_appHidden(x)) x, ]); for (final q in _panes) { - q.marked.removeAll(targets); + targets.forEach(q.sel.forget); q.changed(); } if (mounted) setState(() {}); @@ -2399,19 +2370,19 @@ class _ExplorerPageState extends State { )); } case 'rename': - if (_isMarked(pane, e.path) && pane.marked.length > 1) { - await _batchRename(pane, pane.marked.toList()); + if (_isMarked(pane, e.path) && pane.sel.marked.length > 1) { + await _batchRename(pane, pane.sel.marked); } else { await _rename(pane, e.path); } case 'copy': - await _transfer(pane, _isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false); + await _transfer(pane, _isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'move': - await _transfer(pane, _isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true); + await _transfer(pane, _isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'clipCopy': - _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false); + _toClipboard(_isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'clipMove': - _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true); + _toClipboard(_isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'paste': await _paste(pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': @@ -2673,7 +2644,7 @@ class _ExplorerPageState extends State { /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) List _targets(_Pane pane) { - if (pane.marked.isNotEmpty) return pane.marked.toList(); + if (!pane.sel.isEmpty) return pane.sel.marked; final f = pane.focused; if (f == null || samePath(f, pane.root)) return const []; return [f]; @@ -2762,7 +2733,7 @@ class _ExplorerPageState extends State { // 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다) final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList(); if (videos.isNotEmpty) { - await _play(videos, keepOrder: pane.marked.isNotEmpty); + await _play(videos, keepOrder: !pane.sel.isEmpty); } else { await _playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current); } @@ -2783,9 +2754,8 @@ class _ExplorerPageState extends State { await _refreshAll([parent]); // Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다 if (widget.rsync) { - pane.marked - ..clear() - ..add(made); + pane.sel.clear(); + await pane.sel.toggle(made, isDir: true, list: (_) async => const []); } pane.focused = made; pane.changed(); @@ -2902,7 +2872,7 @@ class _ExplorerPageState extends State { await _followHidden(moved: [(path, now)]); await _refreshAll([vDirname(path)]); for (final x in _panes) { - if (x.marked.remove(path)) x.marked.add(now); + x.sel.rename(path, now); } pane.focused = now; pane.changed(); @@ -2953,7 +2923,7 @@ class _ExplorerPageState extends State { await _refreshAll({for (final (a, _) in moved) vDirname(a)}); for (final x in _panes) { for (final (from, to) in moved) { - if (x.marked.remove(from)) x.marked.add(to); + x.sel.rename(from, to); if (x.focused != null && samePath(x.focused!, from)) x.focused = to; } x.changed(); @@ -3206,8 +3176,8 @@ class _ExplorerPageState extends State { if (!sent.contains(x) && !undoable.contains(x) && !failed.any((f) => f.$1 == x)) x, ]); for (final x in _panes) { - x.marked.removeAll(paths); - if (_selecting && x.marked.isEmpty) _selecting = false; + paths.forEach(x.sel.forget); + if (_selecting && x.sel.isEmpty) _selecting = false; x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s))); if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first); } @@ -3462,7 +3432,7 @@ class _ExplorerPageState extends State { final problem = transferProblem(sources, dest, move: move); if (problem != null) { _snack(problem); - pane.marked.removeWhere((m) => vMissingSync(m)); + pane.sel.removeWhere(vMissingSync); await _refreshAll([dest, ...sources.map(p.dirname)]); for (final x in _panes) { x.changed(); @@ -3600,7 +3570,7 @@ class _ExplorerPageState extends State { await _followMovedJob(job); if (!mounted) return false; // 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다) - pane.marked.clear(); + pane.sel.clear(); await _refreshAfterTransfer(sources, dest); // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 for (final x in _panes) { @@ -3666,7 +3636,7 @@ class _ExplorerPageState extends State { } Future _runRsyncInner({required bool right, required bool left}) async { - final l = _panes[0].marked.firstOrNull, r = _panes[1].marked.firstOrNull; + final l = _panes[0].sel.marked.firstOrNull, r = _panes[1].sel.marked.firstOrNull; if (l == null || r == null) { _snack(tr('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.')); return; @@ -3696,7 +3666,7 @@ class _ExplorerPageState extends State { if (problem != null) { _snack(problem); for (final x in _panes) { - x.marked.removeWhere((m) => vMissingSync(m)); + x.sel.removeWhere(vMissingSync); } await _refreshAll([vDirname(l), vDirname(r)]); return; @@ -3930,7 +3900,7 @@ class _ExplorerPageState extends State { if (jobs.isEmpty || !mounted) return; // 원본 폴더까지 지웠으면 고른 표시도 뺀다 for (final x in _panes) { - x.marked.removeWhere((m) => vMissingSync(m)); + x.sel.removeWhere(vMissingSync); } await _refreshAll([l, r, vDirname(l), vDirname(r)]); for (final x in _panes) { @@ -4596,11 +4566,11 @@ class _ExplorerPageState extends State { /// 49: 뒤로 키가 화면을 닫기 전에 할 일이 있는지 (고른 것이 있거나 맨 위 폴더가 아님) bool get _backHasWork => - _selecting || _panes.any((x) => x.marked.isNotEmpty) || (_ready && !samePath(_pane.current, _pane.root)); + _selecting || _panes.any((x) => !x.sel.isEmpty) || (_ready && !samePath(_pane.current, _pane.root)); /// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기) Future _onBack() async { - if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) { + if (_selecting || _panes.any((x) => !x.sel.isEmpty)) { _setSelecting(false); return; } @@ -4633,7 +4603,7 @@ class _ExplorerPageState extends State { // 76: 열린 빠른 필터부터 닫는다 if (_panes[_active].filterOpen) { _closeFilter(_panes[_active]); - } else if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) { + } else if (_selecting || _panes.any((x) => !x.sel.isEmpty)) { _setSelecting(false); } }, @@ -4894,7 +4864,7 @@ class _ExplorerPageState extends State { label: SizedBox( width: double.infinity, child: Text( - '$side · ${vBasename(_panes[k].marked.isNotEmpty ? _panes[k].marked.first : _panes[k].current)}', + '$side · ${vBasename(!_panes[k].sel.isEmpty ? _panes[k].sel.marked.first : _panes[k].current)}', maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -4959,7 +4929,7 @@ class _ExplorerPageState extends State { /// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나 List _dragPaths(_Pane pane, FileEntry e) => - _isMarked(pane, e.path) && pane.marked.isNotEmpty ? outermostPaths(pane.marked) : [e.path]; + _isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path]; /// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널). /// 이동이 끝나면 아래 알림에 [되돌리기] (10초) @@ -5060,7 +5030,7 @@ class _ExplorerPageState extends State { // 74: 고른 개수와 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고) var size = 0, folders = 0; final byPath = {for (final l in pane.cache.values) for (final x in l) x.path: x}; - for (final m in pane.marked) { + for (final m in pane.sel.marked) { final x = byPath[m]; if (x == null) continue; if (x.isDir) { @@ -5079,7 +5049,7 @@ class _ExplorerPageState extends State { Flexible( child: Text( [ - trf('{0}개 표시함', [pane.marked.length]), + trf('{0}개 표시함', [pane.sel.marked.length]), if (size > 0) formatSize(size), if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]), ].join(' · '), @@ -5120,12 +5090,11 @@ class _ExplorerPageState extends State { TextButton( onPressed: () => setState(() { final dir = listDir ?? pane.current; - pane.marked.addAll([for (final x in _shown(pane, dir)) x.path]); - _normalizeMarks(pane); + pane.sel.addAll([for (final x in _shown(pane, dir)) x.path]); }), child: Text(tr('모두 선택')), ), - TextButton(onPressed: () => setState(pane.marked.clear), child: Text(tr('표시 지우기'))), + TextButton(onPressed: () => setState(pane.sel.clear), child: Text(tr('표시 지우기'))), TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))), ]), ); @@ -5187,7 +5156,7 @@ class _ExplorerPageState extends State { if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)), ]), ), - if ((_selecting || (_alwaysMarks && pane.marked.isNotEmpty)) && !foldersOnly) _marksBar(pane), + if ((_selecting || (_alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) _marksBar(pane), ]); }); } @@ -5879,6 +5848,8 @@ class _ExplorerPageState extends State { }); final real = !row.isRoot && !row.isUp; + // 길게 · 오른쪽 = 메뉴 (규칙 2, [ClickPolicy]) + final menu = ClickPolicy.decide(Press.longPress, _kindOf(row), selectMode: _selecting) == ClickAction.menu; Widget cell(String t, int flex, {TextAlign align = TextAlign.start}) => Expanded( flex: flex, child: Text(t, style: dim, textAlign: align, maxLines: 1, overflow: TextOverflow.ellipsis), @@ -5906,8 +5877,8 @@ class _ExplorerPageState extends State { Widget line = GestureDetector( // Rsync 화면은 고르기만 (파일 기능 메뉴 없음) // 25: Rsync 화면에서도 (폴더 메뉴) - onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null, - onLongPressStart: real ? (d) => _menu(pane, e, d.globalPosition) : null, + onSecondaryTapUp: menu ? (d) => _menu(pane, e, d.globalPosition) : null, + onLongPressStart: menu ? (d) => _menu(pane, e, d.globalPosition) : null, child: Material( color: marked ? JjColors.accent.withValues(alpha: 0.18) @@ -5920,7 +5891,8 @@ class _ExplorerPageState extends State { onTap: () => _onTap(pane, row, list: list), // 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게) // 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게) - onDoubleTap: _selectMode && !_selecting && !e.isDir && !row.isUp + onDoubleTap: ClickPolicy.decide(Press.doubleTap, _kindOf(row), selectMode: _selecting, tapOpens: !_selectMode) == + ClickAction.open ? () => _onOpen(pane, row, list: list) : null, child: Padding( diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index f15acaa..4ed9275 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -1022,18 +1022,6 @@ class _SettingsPageState extends State { value: s.explorerSwipePanes, onChanged: (v) => c.updateSettings((x) => x.explorerSwipePanes = v), ), - SettingTile( - title: Text(tr('폴더를 표시하면')), - subtitle: Text(tr('X-plore 식: 1번 = 폴더, 2번 = 안의 파일 전체, 3번 = 풀기')), - trailing: DropdownButton( - value: s.explorerFolderMarkRule, - items: [ - DropdownMenuItem(value: 'tree', child: Text(tr('폴더와 안의 것 전체 (기본)'))), - DropdownMenuItem(value: 'xplore', child: Text(tr('X-plore 식 (1 · 2 · 3번)'))), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerFolderMarkRule = v!), - ), - ), // X-plore 비교 c: 저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에 (X-plore 스타일) SwitchListTile( title: Text(tr('저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에')), diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 96c149c..1675fde 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -306,29 +306,6 @@ void main() { expect(find.byKey(const ValueKey('k2:\u0000favorites')), findsNothing); }); - testWidgets('X-plore ⚖: 폴더 표시 X-plore 식 (1번 폴더 · 2번 안의 파일 · 3번 풀기) - 처음 값은 지금 규칙', (tester) async { - expect(AppSettings().explorerFolderMarkRule, 'tree', reason: '고르는 범위가 바뀌면 지우기 · 이동 대상이 바뀌어 처음 값은 그대로'); - c.settings.explorerFolderMarkRule = 'xplore'; - await open(tester); - IconData markIcon(String name) => (tester - .widget(find.descendant( - of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, matching: find.byType(IconButton))) - .icon as Icon) - .icon!; - Future mark() async { - // 표시 동그라미 (풍선 도움말이 표시 여부에 따라 바뀌므로 줄 안의 단추로 찾는다) - await act(tester, () => tester.tap(find.descendant(of: find.ancestor(of: find.text('sub'), matching: find.byType(Material)).first, matching: find.byType(IconButton)).first)); - await settle(tester, () => false, rounds: 10); - } - - await mark(); - expect((markIcon('sub'), markIcon('inner.txt')), (Icons.check_circle, Icons.check_circle), reason: '1번 = 폴더 자신 (안의 것도 포함)'); - await mark(); - expect((markIcon('sub'), markIcon('inner.txt')), (Icons.check_circle_outline, Icons.check_circle), reason: '2번 = 안의 파일 전체'); - await mark(); - expect((markIcon('sub'), markIcon('inner.txt')), (Icons.check_circle_outline, Icons.check_circle_outline), reason: '3번 = 풀기'); - }); - testWidgets('X-plore ⚖: 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (한 창이면 숨은 창과 바꿈)', (tester) async { expect(AppSettings().explorerSwipePanes, isFalse, reason: '처음 값은 끔 - 화면 가운데를 밀면 화면 이동 (10/8 사용자 결정)'); c.settings.explorerSwipePanes = true; @@ -608,11 +585,7 @@ void main() { expect(markIcon('inner.txt'), Icons.check_circle_outline); expect(markIcon('second.txt'), Icons.check_circle); expect(markIcon('sub'), Icons.check_circle_outline); - // 폴더를 다시 누르면 다시 전체 선택, 한 번 더 누르면 자기 자신과 안의 것 모두 취소 - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => false, rounds: 5); - expect(find.textContaining('1개 표시함'), findsOneWidget); - expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(Icons.check_circle)); + // 하나를 뺀 폴더를 다시 누르면 그 폴더와 안의 것 모두 취소 (관리자 결정 2, 10/22 - 규칙 6 "다시 누르면 전체 취소") await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => false, rounds: 5); expect(find.textContaining('0개 표시함'), findsNWidgets(2)); diff --git a/test/selection_contract_test.dart b/test/selection_contract_test.dart index ceb4e16..007d2a5 100644 --- a/test/selection_contract_test.dart +++ b/test/selection_contract_test.dart @@ -33,6 +33,13 @@ void main() { expect(ClickPolicy.decide(press, kind, selectMode: sel, expanded: exp, list: list, repeat: rep), want); }); } + test('사용자 설정: 바로 열기 · 다시 눌러 접지 않기', () { + expect(ClickPolicy.decide(Press.tap, ItemKind.file, selectMode: false, tapOpens: true), ClickAction.open); + expect(ClickPolicy.decide(Press.doubleTap, ItemKind.file, selectMode: false, tapOpens: true), ClickAction.none); + expect(ClickPolicy.decide(Press.tap, ItemKind.file, selectMode: true, tapOpens: true), ClickAction.toggleMark); + expect(ClickPolicy.decide(Press.tap, ItemKind.folder, selectMode: false, expanded: true, retapCollapses: false), + ClickAction.expand); + }); }); group('Selection (규칙 5 · 6 · 8)', () { @@ -140,6 +147,25 @@ void main() { expect(s.targets(null), isEmpty); }); + test('이름 바꾸기 · 없어진 것 따라가기', () async { + final s = Selection(); + await s.toggle(show, isDir: true, list: lister); + await s.toggle(e1, isDir: false, list: lister); // Show 를 나눔: e2 · e3 · Extras 표시 + await s.toggle(notes, isDir: false, list: lister); + final moved = p.join(tmp.path, 'Season'); + s.rename(show, moved); + expect(s.has(p.join(moved, 'ep02.mkv')), isTrue); + expect(s.isMarked(p.join(moved, 'Extras', 'bonus.mkv')), isTrue); + expect(s.has(e2), isFalse); + s.rename(notes, p.join(tmp.path, 'memo.txt')); + expect(s.has(p.join(tmp.path, 'memo.txt')), isTrue); + s.removeWhere((x) => x.endsWith('.txt')); + expect(s.marked.any((x) => x.endsWith('.txt')), isFalse); + // 나눈 폴더도 새 이름으로 - 다시 누르면 전체 취소 + await s.toggle(moved, isDir: true, list: lister); + expect(s.isEmpty, isTrue); + }); + test('Rsync: 폴더만 · 하나만 · 다시 누르면 취소 (규칙 8)', () async { final s = Selection(rsync: true); await s.toggle(e1, isDir: false, list: lister);