From 0f3133cc68671349d0f0f1ef679a564a9b828601 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:57:25 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2015=20=EC=BA=A1=EC=B2=98=20=EC=A7=80?= =?UTF-8?q?=EC=A0=81=20=E2=91=A0~=E2=91=A3:=20=EC=98=81=EC=96=B4=20?= =?UTF-8?q?=EB=8B=A8=EC=88=98=20=C2=B7=20=EC=A2=81=EC=9D=80=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=92=80=20=EA=B3=B3=20=EC=84=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=B0=BE=EA=B8=B0/=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=A4=84=EC=9D=80=20=EC=8B=9C=EC=9E=91=ED=95=9C=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EA=B8=B0=EC=A4=80=20=EC=A7=A7=EC=9D=80=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=C2=B7=20=EC=BA=A1=EC=B2=98=20DEBUG=20?= =?UTF-8?q?=EB=9D=A0=20=EB=81=94=20(=EA=B2=80=ED=86=A0=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (15 캡처 지적, 버그 수정): ① 영어 "1 problems" · "1 names": englishSingular (61) 에 problems · names · paths 를 더하고, 수와 명사 사이 꾸미는 말 하나도 넘게 ("1 incomplete files" → "1 incomplete file"). 78 영어 글 "until fixed". ② 86 풀 곳: 폭 720 미만이면 칩 대신 세로 라디오 목록 (글이 다 보임). ZIP 묶기의 넣을 곳도 같은 도우미. ③ 79 찾기 결과 · 87 큰 파일 줄: 시작한 폴더 이름부터의 상대 경로 (shortUnder), 길면 가운데 줄임 (앞 … 마지막 두 단계), 전체 경로는 마우스를 올리거나 그 줄을 길게 누르면 (Tooltip). ④ 캡처 시험 debugShowCheckedModeBanner: false. 내 판단: ① 은 개수별 글을 따로 두지 않고 기존 61 의 단수 처리를 넓힘 (같은 꼴의 다른 글도 함께 고쳐짐). ② 를 ZIP 묶기 창에도 적용 (같은 모양). Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 +- integration_test/windows_b6_shot_test.dart | 4 +++- lib/core/file_ops.dart | 21 +++++++++++++++++ lib/l10n/tr.dart | 6 +++-- lib/ui/archive_ui.dart | 27 +++++++++++++++++----- lib/ui/explorer_search.dart | 21 ++++++++++------- lib/ui/storage_page.dart | 8 ++++++- test/archive_ui_test.dart | 27 ++++++++++++++++++++-- test/explorer_search_test.dart | 14 +++++++++++ test/i18n_small_test.dart | 4 ++++ 10 files changed, 113 insertions(+), 21 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 769c83b..30dc83e 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1114,7 +1114,7 @@ "문서 미리보기": "Document preview", "문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first", "문자셋 경고": "Charset warning", - "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until they're fixed", + "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until fixed", "묻기": "Ask", "묻기 (기본)": "Ask (default)", "물어보지 않기": "Never ask", diff --git a/integration_test/windows_b6_shot_test.dart b/integration_test/windows_b6_shot_test.dart index efb10fa..bd103a0 100644 --- a/integration_test/windows_b6_shot_test.dart +++ b/integration_test/windows_b6_shot_test.dart @@ -55,7 +55,9 @@ void main() { final shotKey = GlobalKey(); await t.pumpWidget(RepaintBoundary( key: shotKey, - child: MaterialApp(theme: buildTheme(), navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())), + // DEBUG 띠가 오른쪽 위 단추 (79 [모두 선택]) 를 가리지 않게 (15 지적) + child: MaterialApp( + theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())), )); BuildContext ctx() => navKey.currentContext!; diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 6b94126..823a8a6 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -141,6 +141,27 @@ bool samePath(String a, String b) { return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y; } +/// 79 · 87: 찾기 · 분석 결과 줄의 폴더 - 시작한 폴더 이름부터의 상대 경로 ("Videos\Show\S1"). +/// 단계가 [keep] + 1 보다 많으면 가운데를 줄인다 ("Videos\…\S1\E01"). 시작한 폴더 밖이면 전체 경로. +/// (15 캡처: 폭 412 에서 절대 경로의 앞부분만 보여 구별에 필요한 아래 폴더가 잘림) +String shortUnder(String root, String dir, {int keep = 2}) { + final parts = []; + var x = dir; + for (var i = 0; i < 256 && !samePath(x, root); i++) { + final up = vDirname(x); + if (up == x) return vDisplay(dir); + parts.insert(0, vBasename(x)); + x = up; + } + if (!samePath(x, root)) return vDisplay(dir); + // 드라이브 · 서버 맨 위는 이름이 없어 그대로 (C: · ☁ 서버) + final top = vDirname(root) == root; + parts.insert(0, top ? (isDav(root) ? vDisplay(root) : root.replaceAll(RegExp(r'[\\/]+$'), '')) : vBasename(root)); + final sep = isDav(dir) ? '/' : p.separator; + if (parts.length > keep + 1) return [parts.first, '…', ...parts.sublist(parts.length - keep)].join(sep); + return parts.join(sep); +} + /// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게) bool isSameOrInside(String a, String b) { final dav = davInside(a, b); diff --git a/lib/l10n/tr.dart b/lib/l10n/tr.dart index 77b8833..a6d789c 100644 --- a/lib/l10n/tr.dart +++ b/lib/l10n/tr.dart @@ -39,7 +39,8 @@ String trf(String ko, List args) { } /// 수가 1 인 영어 복수 명사를 단수로 ("1 videos" · "(1 files)" → "1 video" · "(1 file)"). 11 · 21 · 1.5 는 그대로. -String englishSingular(String s) => s.replaceAllMapped(_one, (m) => '${m[1]}1 ${_singularOf[m[2]!]}'); +/// 수와 명사 사이의 꾸미는 말 하나도 넘는다 ("1 incomplete files" → "1 incomplete file"). +String englishSingular(String s) => s.replaceAllMapped(_one, (m) => '${m[1]}1 ${m[2] ?? ''}${_singularOf[m[3]!]}'); const _singularOf = { 'videos': 'video', 'files': 'file', 'items': 'item', 'subtitles': 'subtitle', 'downloads': 'download', @@ -48,6 +49,7 @@ const _singularOf = { 'servers': 'server', 'tracks': 'track', 'languages': 'language', 'models': 'model', 'bookmarks': 'bookmark', 'errors': 'error', 'seconds': 'second', 'minutes': 'minute', 'hours': 'hour', 'days': 'day', 'tasks': 'task', 'copies': 'copy', 'entries': 'entry', 'episodes': 'episode', 'changes': 'change', 'components': 'component', + 'names': 'name', 'paths': 'path', 'problems': 'problem', }; -final _one = RegExp('(^|[^\\d.,])1 (${_singularOf.keys.join('|')})\\b'); +final _one = RegExp('(^|[^\\d.,])1 ((?!of )[A-Za-z][\\w-]* )?(${_singularOf.keys.join('|')})\\b'); diff --git a/lib/ui/archive_ui.dart b/lib/ui/archive_ui.dart index b34fe0e..96bb6d9 100644 --- a/lib/ui/archive_ui.dart +++ b/lib/ui/archive_ui.dart @@ -11,6 +11,7 @@ import '../core/archive_ops.dart'; import '../core/file_ops.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; +import 'app_actions.dart' show isCompact; import 'theme.dart'; /// 86: 동봉한 7-Zip. 없으면 null @@ -200,9 +201,7 @@ Future extractArchiveFlow(BuildContext context, AppController c, String Text(tr('비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ], const SizedBox(height: 10), - Wrap(spacing: 6, runSpacing: 4, children: [ - for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: dest == d, onSelected: (_) => set(() => dest = d)), - ]), + _placeChoice(ctx, choices, dest, (d) => set(() => dest = d)), Text(vDisplay(dest), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 6), Row(children: [ @@ -286,6 +285,24 @@ Future extractArchiveFlow(BuildContext context, AppController c, String } } +/// 86: 풀 곳 · 넣을 곳 고르기. 넓으면 칩, 좁으면 (폭 720 미만) 글이 다 보이게 세로 목록 (15 캡처: 412 에서 칩 글이 잘림) +Widget _placeChoice(BuildContext context, List<(String, String)> choices, String value, void Function(String) onPick) { + if (!isCompact(context)) { + return Wrap(spacing: 6, runSpacing: 4, children: [ + for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: value == d, onSelected: (_) => onPick(d)), + ]); + } + return RadioGroup( + groupValue: value, + onChanged: (d) { + if (d != null) onPick(d); + }, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + for (final (d, label) in choices) RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: d, title: Text(label)), + ]), + ); +} + /// 86: ZIP 으로 묶기 - 이름 · 압축 정도 (설정으로 기억) · 넣을 곳. 링크 · junction 은 넣지 않는다. 만든 ZIP 경로 (취소 · 실패면 null) Future createZipFlow(BuildContext context, AppController c, List sources, {required String dir, String? otherDir}) async { final messenger = ScaffoldMessenger.maybeOf(context); @@ -329,9 +346,7 @@ Future createZipFlow(BuildContext context, AppController c, List set(() => level = v)), ]), const SizedBox(height: 6), - Wrap(spacing: 6, runSpacing: 4, children: [ - for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: where == d, onSelected: (_) => set(() => where = d)), - ]), + _placeChoice(ctx, choices, where, (d) => set(() => where = d)), Text(trf('만들 파일: {0}', [p.basename(target())]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('링크 · junction 은 넣지 않습니다'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ]), diff --git a/lib/ui/explorer_search.dart b/lib/ui/explorer_search.dart index 70a7190..ce434cf 100644 --- a/lib/ui/explorer_search.dart +++ b/lib/ui/explorer_search.dart @@ -417,14 +417,19 @@ class _SearchResultsPageState extends State { onChanged: (_) => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)), ), title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - [ - vDirname(e.path), - if (!e.isDir) formatSize(e.size), - if (e.modified.year > 1) _date(e.modified), - ].join(' · '), - maxLines: 1, - overflow: TextOverflow.ellipsis, + // 폴더는 찾기 시작한 곳 기준으로 짧게 - 전체 경로는 마우스를 올리거나 이 줄을 길게 누르면 + subtitle: Tooltip( + message: vDisplay(vDirname(e.path)), + triggerMode: TooltipTriggerMode.longPress, + child: Text( + [ + shortUnder(widget.root, vDirname(e.path)), + if (!e.isDir) formatSize(e.size), + if (e.modified.year > 1) _date(e.modified), + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), trailing: IconButton( tooltip: tr('그 자리로'), diff --git a/lib/ui/storage_page.dart b/lib/ui/storage_page.dart index 3dcf49c..4bef8f6 100644 --- a/lib/ui/storage_page.dart +++ b/lib/ui/storage_page.dart @@ -199,7 +199,13 @@ class _StorageAnalysisPageState extends State { const SizedBox(height: 4), _bar(top == 0 ? 0 : f.size / top), const SizedBox(height: 2), - Text('${formatSize(f.size)} · ${vDisplay(vDirname(f.path))}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + // 폴더는 분석한 곳 기준으로 짧게 - 전체 경로는 마우스를 올리거나 길게 누르면 + Tooltip( + message: vDisplay(vDirname(f.path)), + triggerMode: TooltipTriggerMode.longPress, + child: Text('${formatSize(f.size)} · ${shortUnder(widget.root, vDirname(f.path))}', + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + ), ]), // 누르면 탐색기에서 그 파일 자리로 (지우기 등은 탐색기에서) onTap: () => Navigator.pop(context, f.path), diff --git a/test/archive_ui_test.dart b/test/archive_ui_test.dart index 1239239..3a82fe0 100644 --- a/test/archive_ui_test.dart +++ b/test/archive_ui_test.dart @@ -27,8 +27,8 @@ void main() { } } - Future Function(Future Function(BuildContext))> host(WidgetTester tester) async { - tester.view.physicalSize = const Size(1000, 900); + Future Function(Future Function(BuildContext))> host(WidgetTester tester, {Size size = const Size(1000, 900)}) async { + tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final key = GlobalKey(); @@ -81,6 +81,29 @@ void main() { } }); + testWidgets('풀 곳: 좁은 화면 (412) 은 글이 다 보이는 세로 목록 · 고른 곳에 풂 (15 캡처: 칩 글 잘림)', (tester) async { + final zip = p.join(tmp.path, 'sample.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive()..addFile(ArchiveFile.string('a.txt', 'a')))); + final other = Directory(p.join(tmp.path, 'other'))..createSync(); + final run = await host(tester, size: const Size(412, 900)); + String? out; + var finished = false; + run((ctx) => extractArchiveFlow(ctx, c, zip, otherDir: other.path)).then((v) { + out = v; + finished = true; + }); + await wait(tester, () => find.text('풀기').evaluate().isNotEmpty); + expect(find.byType(ChoiceChip), findsNothing); + expect(find.byType(RadioListTile), findsNWidgets(3)); + expect(find.text('압축 옆 새 폴더 "sample"'), findsOneWidget); + await tester.tap(find.text('다른 창 폴더')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, '풀기')); + await wait(tester, () => finished); + expect(out, other.path); + expect(File(p.join(other.path, 'a.txt')).existsSync(), isTrue); + }); + testWidgets('ZIP 으로 묶기: 이름 · 압축 정도 (기억) → 만든 ZIP', (tester) async { final src = Directory(p.join(tmp.path, 'photos'))..createSync(); File(p.join(src.path, 'a.txt')).writeAsStringSync('A'); diff --git a/test/explorer_search_test.dart b/test/explorer_search_test.dart index 1ca7a4f..5d3634b 100644 --- a/test/explorer_search_test.dart +++ b/test/explorer_search_test.dart @@ -23,6 +23,20 @@ void main() { expect(c.summary, contains('1.0GB')); }); + test('결과 줄의 폴더: 시작한 폴더 이름부터 · 길면 가운데 줄임 · 밖이면 전체 (15 캡처)', () { + final root = p.join(Directory.systemTemp.path, 'Videos'); + String j(List xs) => xs.join(p.separator); + expect(shortUnder(root, root), 'Videos'); + expect(shortUnder(root, p.join(root, 'Show')), j(['Videos', 'Show'])); + expect(shortUnder(root, p.join(root, 'Show', 'S1')), j(['Videos', 'Show', 'S1'])); + expect(shortUnder(root, p.join(root, 'Show', 'S1', 'E01', 'sub')), j(['Videos', '…', 'E01', 'sub'])); + final out = p.join(Directory.systemTemp.path, 'Other', 'x'); + expect(shortUnder(root, out), out); + final drive = p.rootPrefix(Directory.systemTemp.path); + expect(shortUnder(drive, p.join(drive, 'A')), startsWith(drive.replaceAll(RegExp(r'[\\/]+$'), ''))); + expect(shortUnder(drive, p.join(drive, 'A')), isNot(contains('${p.separator}${p.separator}'))); + }); + test('조건: N일 안 · 확장자 직접 · 조건 없음', () { expect(const SearchConditions(newerDays: 7).accepts(f('a', daysAgo: 3), now: now), isTrue); expect(const SearchConditions(newerDays: 7).accepts(f('a', daysAgo: 9), now: now), isFalse); diff --git a/test/i18n_small_test.dart b/test/i18n_small_test.dart index bb3ebac..f962579 100644 --- a/test/i18n_small_test.dart +++ b/test/i18n_small_test.dart @@ -18,6 +18,10 @@ void main() { expect(englishSingular('1 copies · 1 entries'), '1 copy · 1 entry'); expect(englishSingular('11 videos · 21 files · 1.1 videos · 0,1 items'), '11 videos · 21 files · 1.1 videos · 0,1 items'); expect(englishSingular('2 videos'), '2 videos'); + // 꾸미는 말 하나 · 새 명사 (15 캡처 지적: "1 problems" · "1 names") + expect(englishSingular('1 incomplete files · the 1 checked videos'), '1 incomplete file · the 1 checked video'); + expect(englishSingular('1 problems · 1 names · Copied 1 paths'), '1 problem · 1 name · Copied 1 path'); + expect(englishSingular('2 incomplete files · 1 of videos · 1 big old files'), '2 incomplete files · 1 of videos · 1 big old files'); }); test('영어 화면에서 trf 가 단수를 쓴다 (한국어 · 일본어는 그대로)', () async {