From 8eb167438291cf2c99126b92b8702e4e3650fd0d Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:43:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?WIP=20X-plore=20P3=20=EB=B3=B4=EC=B6=A9=20(?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=EC=97=90=EC=84=9C=20=EC=B0=BE=EC=9D=8C)=20?= =?UTF-8?q?=C2=B7=20=EA=B2=BD=EB=A1=9C=20=EB=A7=89=EB=8C=80=20=EB=88=84?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=EB=A1=9C=20=EC=9C=84=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=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 캡처 (integration_test/windows_p3_shot_test.dart - 해시 · 비교 · 폴더 속성 · 16진, 412/960 × ko/en/ja, release\test\shots_p3_fix) 에서 찾아 고침: - 16진 보기: 폭 412 에서 한 줄 16 바이트면 끝의 글자 칸이 화면 밖으로 잘렸다 → 폭 560 미만은 한 줄 8 바이트 (line(per:), 처음 값 16 그대로) - 해시 창: 닫자마자 글 칸 컨트롤러를 dispose 해 닫히는 동안 "A TextEditingController was used after being disposed" (닫을 때마다) → 닫힌 뒤 dispose 관리자 지시 3 (X-plore 비교표의 작은 것): 위 막대의 경로를 누르면 지금 폴더 · 위 폴더들 (창의 맨 위까지, 가까운 것부터) 이 나와 고르면 그리로 (X-plore 4.4x 경로 막대). 지금 폴더는 고를 수 없게 흐리게. 시험: explorer_test (새: 경로 막대 - 지금 폴더 못 고름 · 바로 위 = left · 고르면 하나 적어짐) · file_tools_test 통과, analyze 문제 없음, l10n 빠짐 0, 캡처 시험 6 통과. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 1 + assets/l10n/ja.json | 1 + assets/l10n/zh-Hans.json | 1 + integration_test/windows_p3_shot_test.dart | 84 ++++++++++++++++++++++ lib/ui/explorer_page.dart | 34 ++++++++- lib/ui/file_tools.dart | 17 +++-- test/explorer_test.dart | 19 +++++ 7 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 integration_test/windows_p3_shot_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 5249f82..3514bd9 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1778,6 +1778,7 @@ "위 ⇄ 아래": "Top ⇄ bottom", "위 폴더": "Parent folder", "위 폴더로": "Go up", + "위 폴더로 가기": "Go to a parent folder", "위로": "Up", "위아래": "Top and bottom", "위아래 ⇅": "Stacked ⇅", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 4af21e1..0e18cf4 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1778,6 +1778,7 @@ "위 ⇄ 아래": "上 ⇄ 下", "위 폴더": "上のフォルダー", "위 폴더로": "上のフォルダーへ", + "위 폴더로 가기": "上のフォルダーへ移動", "위로": "上へ", "위아래": "上下", "위아래 ⇅": "上下 ⇅", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 569e4ab..9fa6dc0 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1778,6 +1778,7 @@ "위 ⇄ 아래": "上 ⇄ 下", "위 폴더": "上级文件夹", "위 폴더로": "返回上级文件夹", + "위 폴더로 가기": "转到上级文件夹", "위로": "向上", "위아래": "上下", "위아래 ⇅": "上下 ⇅", diff --git a/integration_test/windows_p3_shot_test.dart b/integration_test/windows_p3_shot_test.dart new file mode 100644 index 0000000..0fc0f72 --- /dev/null +++ b/integration_test/windows_p3_shot_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/ui/file_tools.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// 관리자 지시 (10/18): X-plore P3 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더) - 해시 창 · 폴더 속성 · 16진 보기. +/// 크기 412 (폰 세로) · 960, 언어 ko · en · ja. 시험 파일은 임시 폴더에만 (끝나면 지움). 실행: flutter test integration_test\windows_p3_shot_test.dart -d windows +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_p3'); + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('P3 화면 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = Directory.systemTemp.createTempSync('jj_p3shot_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final file = File(p.join(tmp.path, '영상 폴더', 'ep01 긴 이름의 동영상 파일.mkv')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.generate(5000, (i) => i % 256)); + File(p.join(tmp.path, '영상 폴더', '속', 'a.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('hello'); + final navKey = GlobalKey(); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp( + theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())), + )); + BuildContext ctx() => navKey.currentContext!; + Future settle([int n = 8]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(3); + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'p3_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + Future closeAll() async { + navKey.currentState!.popUntil((r) => r.isFirst); + await settle(4); + } + + // 해시 창 (다 계산된 뒤 · 붙여 넣은 값과 비교) + showFileHash(ctx(), file.path); + await settle(12); + await shot('hash'); + await t.enterText(find.byType(TextField).first, 'abc'); + await settle(); + await shot('hash_compare'); + await closeAll(); + // 폴더 속성 + showFolderProperties(ctx(), p.join(tmp.path, '영상 폴더')); + await settle(12); + await shot('props'); + await closeAll(); + // 16진 보기 + navKey.currentState!.push(MaterialPageRoute(builder: (_) => HexViewerPage(path: file.path))); + await settle(12); + await shot('hex'); + await closeAll(); + }); + } + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index f174275..c26f385 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3698,15 +3698,45 @@ class _ExplorerPageState extends State { ); } + /// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터) + List _ancestors(_Pane pane) { + final out = []; + var d = pane.current; + for (var i = 0; i < 64; i++) { + out.add(d); + if (samePath(d, pane.root)) break; + final up = vDirname(d); + if (up == d || up.isEmpty) break; + d = up; + } + return out; + } + Widget _topBar() => AppTopBar( nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync), actions: AppActions(c: c), middle: Row(children: [ Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(width: 12), + // X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다 Expanded( - child: Text(_ready ? _displayPath(_pane.current) : '', - maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), + child: !_ready + ? const SizedBox.shrink() + : PopupMenuButton( + tooltip: tr('위 폴더로 가기'), + position: PopupMenuPosition.under, + onSelected: (d) => _goTo(_pane, d), + itemBuilder: (_) => [ + for (final d in _ancestors(_pane)) + PopupMenuItem( + value: d, + enabled: !samePath(d, _pane.current), + child: Text(_displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + child: Text(_displayPath(_pane.current), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), + ), ), PopupMenuButton( tooltip: tr('창 배치 · 버튼 구성'), diff --git a/lib/ui/file_tools.dart b/lib/ui/file_tools.dart index ff546fa..a3a5a8e 100644 --- a/lib/ui/file_tools.dart +++ b/lib/ui/file_tools.dart @@ -112,7 +112,8 @@ Future showFileHash(BuildContext context, String path) async { ); }), ); - compare.dispose(); + // 창이 닫히는 동안 (애니메이션) 글 칸이 아직 쓰므로 바로 dispose 하면 "used after being disposed" (15 캡처) - 닫힌 뒤에 + Future.delayed(const Duration(milliseconds: 500), compare.dispose); } /// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기] @@ -121,12 +122,12 @@ class HexViewerPage extends StatefulWidget { final int chunk; const HexViewerPage({super.key, required this.path, this.chunk = 64 * 1024}); - /// 한 줄 (16 바이트): "00000010 41 42 ... AB.." - static String line(int offset, List bytes) { + /// 한 줄 ([per] 바이트, 처음 16): "00000010 41 42 ... AB.." + static String line(int offset, List bytes, {int per = 16}) { final hex = StringBuffer(); - for (var i = 0; i < 16; i++) { + for (var i = 0; i < per; i++) { hex.write(i < bytes.length ? bytes[i].toRadixString(16).padLeft(2, '0') : ' '); - hex.write(i == 7 ? ' ' : ' '); + hex.write(i == 7 && per > 8 ? ' ' : ' '); } final text = String.fromCharCodes([for (final b in bytes) b >= 0x20 && b < 0x7f ? b : 0x2e]); return '${offset.toRadixString(16).padLeft(8, '0')} $hex $text'; @@ -163,7 +164,9 @@ class _HexViewerPageState extends State { @override Widget build(BuildContext context) { - final rows = (_data.length + 15) ~/ 16; + // 좁은 화면 (폰 세로) 은 한 줄 8 바이트 - 16 이면 끝의 글자 칸이 화면 밖으로 잘렸다 (15 캡처, 412 폭) + final per = MediaQuery.sizeOf(context).width < 560 ? 8 : 16; + final rows = (_data.length + per - 1) ~/ per; return Scaffold( appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)), body: _error != null @@ -176,7 +179,7 @@ class _HexViewerPageState extends State { itemCount: rows, itemExtent: 18, itemBuilder: (_, i) => Text( - HexViewerPage.line(i * 16, _data.sublist(i * 16, (i * 16 + 16).clamp(0, _data.length))), + HexViewerPage.line(i * per, _data.sublist(i * per, (i * per + per).clamp(0, _data.length)), per: per), style: const TextStyle(fontFamily: 'monospace', fontSize: 12), maxLines: 1, softWrap: false, diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 0c2f7ad..52b4167 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -193,6 +193,25 @@ void main() { await calm(tester); }); + testWidgets('X-plore 경로 막대: 위 막대의 경로를 누르면 지금 폴더 · 위 폴더들 (창의 맨 위까지) - 고르면 그리로', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기'))); + await calm(tester); + final items = find.byType(PopupMenuItem); + final n = items.evaluate().length; + expect(n, greaterThanOrEqualTo(2), reason: 'sub · 그 위 폴더들 (창의 맨 위까지)'); + expect(tester.widget>(items.first).enabled, isFalse, reason: '지금 폴더는 고를 수 없음'); + expect(tester.widget>(items.at(1)).value, left, reason: '바로 위 = left'); + await act(tester, () => tester.tap(items.at(1))); + await settle(tester, () => false, rounds: 15); + // 위로 갔으면 다시 열 때 하나 적다 (지금 폴더 = left) + await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기'))); + await calm(tester); + expect(find.byType(PopupMenuItem), findsNWidgets(n - 1)); + }); + testWidgets('X-plore b: 가운데 버튼 길게 누르기 = 짝 동작 - [복사] 길게 = 이동 창 · [삭제] 길게 = 영구 삭제 창', (tester) async { await open(tester); await act(tester, () => tester.tap(find.text('doc.txt'))); From d29593b2e68e43a7884d6028d78d780147e01e38 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:47:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?WIP=20P3=20=EB=B3=B4=EC=B6=A9:=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=86=8D=EC=84=B1=20=C2=B7=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?[=EC=A0=95=EB=B3=B4]=20=EC=97=90=20=EB=A7=8C=EB=93=A0=20?= =?UTF-8?q?=EB=95=8C=20=C2=B7=20=EC=88=A8=EA=B9=80=20=C2=B7=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=C2=B7=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20(=EB=B3=B4=EA=B8=B0=EB=A7=8C)=20(=EA=B2=80=ED=86=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/18). lib/core/file_attributes.dart: Windows 는 FFI GetFileAttributesExW (kernel32, file_ops 의 GetDriveTypeW 와 같은 꼴), 그 밖 (Android) 은 이름이 점으로 시작하면 숨김 · 만든 때는 모름. 바꾸기는 넣지 않음. 폴더 속성 창: 만든 때 · 속성 (없으면 없음). 파일 [정보] 창: 만든 날 · 속성 (원격은 뺌). 시험: file_attributes_test 2 (시험 파일에만 attrib +h +r 을 켜고 끝에 끔 - 숨김 · 읽기 전용 · 폴더 · 없는 경로 null · 만든 때) · file_tools · explorer P3 통과, analyze 문제 없음, l10n 빠짐 0. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 6 +++++ assets/l10n/ja.json | 6 +++++ assets/l10n/zh-Hans.json | 6 +++++ lib/core/file_attributes.dart | 49 ++++++++++++++++++++++++++++++++++ lib/ui/explorer_page.dart | 5 ++++ lib/ui/file_tools.dart | 5 ++++ test/file_attributes_test.dart | 41 ++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+) create mode 100644 lib/core/file_attributes.dart create mode 100644 test/file_attributes_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 3514bd9..98c6117 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1060,6 +1060,8 @@ "마지막으로: {0}": "To the last: {0}", "막은 경로 {0}개": "{0} paths blocked", "만든 그림 · 저장: {0}": "Made images · saved in: {0}", + "만든 날": "Created", + "만든 때: {0}": "Created: {0}", "만들 MKV 가 이미 있으면": "If the MKV already exists", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", "만들 파일: {0}": "File to create: {0}", @@ -1454,6 +1456,8 @@ "속도 제한": "Speed limit", "속도 제한 (KB/s)": "Speed limit (KB/s)", "속도 제한 {0} KB/s": "Speed limit {0} KB/s", + "속성": "Attributes", + "속성: {0}": "Attributes: {0}", "수": "Wed", "수정": "Edit", "수정 · 이동": "Edit · move", @@ -1479,6 +1483,7 @@ "시드 (-1 은 아무렇게나)": "Seed (-1 = random)", "시드 {0}": "Seed {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02", + "시스템": "System", "시스템 DNS": "System DNS", "시스템 DNS (끄기)": "System DNS (off)", "시스템 DNS 로 바꾸기": "Switch to system DNS", @@ -1941,6 +1946,7 @@ "일반 오류": "general failure", "일본어": "Japanese", "일시정지": "Pause", + "읽기 전용": "Read-only", "읽기 전용 매체입니다": "Read-only media", "읽는 중 · 폴더 {0}개 · {1}": "Reading · {0} folders · {1}", "읽을 수 없습니다": "Cannot read", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 0e18cf4..851b15e 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1060,6 +1060,8 @@ "마지막으로: {0}": "最後へ: {0}", "막은 경로 {0}개": "ブロックしたパス {0} 件", "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", + "만든 날": "作成日", + "만든 때: {0}": "作成日時: {0}", "만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", "만들 파일: {0}": "作成するファイル: {0}", @@ -1454,6 +1456,8 @@ "속도 제한": "速度制限", "속도 제한 (KB/s)": "速度制限 (KB/s)", "속도 제한 {0} KB/s": "速度制限 {0} KB/s", + "속성": "属性", + "속성: {0}": "属性: {0}", "수": "水", "수정": "編集", "수정 · 이동": "編集・移動", @@ -1479,6 +1483,7 @@ "시드 (-1 은 아무렇게나)": "シード (-1 はランダム)", "시드 {0}": "シード {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル", + "시스템": "システム", "시스템 DNS": "システム DNS", "시스템 DNS (끄기)": "システム DNS (オフ)", "시스템 DNS 로 바꾸기": "システム DNS に切り替える", @@ -1941,6 +1946,7 @@ "일반 오류": "一般エラー", "일본어": "日本語", "일시정지": "一時停止", + "읽기 전용": "読み取り専用", "읽기 전용 매체입니다": "読み取り専用メディアです", "읽는 중 · 폴더 {0}개 · {1}": "読み込み中 · フォルダー {0} 件 · {1}", "읽을 수 없습니다": "読み込めません", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 9fa6dc0..ecf9a10 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1060,6 +1060,8 @@ "마지막으로: {0}": "到最后: {0}", "막은 경로 {0}개": "已阻止 {0} 个路径", "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", + "만든 날": "创建日期", + "만든 때: {0}": "创建时间:{0}", "만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", "만들 파일: {0}": "要创建的文件: {0}", @@ -1454,6 +1456,8 @@ "속도 제한": "速度限制", "속도 제한 (KB/s)": "速度限制 (KB/s)", "속도 제한 {0} KB/s": "速度限制 {0} KB/s", + "속성": "属性", + "속성: {0}": "属性:{0}", "수": "三", "수정": "编辑", "수정 · 이동": "编辑 · 移动", @@ -1479,6 +1483,7 @@ "시드 (-1 은 아무렇게나)": "种子 (-1 为随机)", "시드 {0}": "种子 {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件", + "시스템": "系统", "시스템 DNS": "系统 DNS", "시스템 DNS (끄기)": "系统 DNS (关闭)", "시스템 DNS 로 바꾸기": "切换为系统 DNS", @@ -1941,6 +1946,7 @@ "일반 오류": "一般错误", "일본어": "日语", "일시정지": "暂停", + "읽기 전용": "只读", "읽기 전용 매체입니다": "只读介质", "읽는 중 · 폴더 {0}개 · {1}": "正在读取 · {0} 个文件夹 · {1}", "읽을 수 없습니다": "无法读取", diff --git a/lib/core/file_attributes.dart b/lib/core/file_attributes.dart new file mode 100644 index 0000000..0dda27f --- /dev/null +++ b/lib/core/file_attributes.dart @@ -0,0 +1,49 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; +import 'package:path/path.dart' as p; + +import '../l10n/tr.dart'; + +/// X-plore P3 보충 (관리자 지시 10/18): 파일 · 폴더의 숨김 · 읽기 전용 · 시스템 속성과 만든 때 - 보기만 (바꾸지 않음). +/// Windows 는 GetFileAttributesExW (kernel32), 그 밖 (Android) 은 이름이 점으로 시작하면 숨김 · 만든 때는 모름. +class FileAttributes { + final bool hidden; + final bool readOnly; + final bool system; + + /// 만든 때 (모르면 null) + final DateTime? created; + const FileAttributes({this.hidden = false, this.readOnly = false, this.system = false, this.created}); + + /// 화면에 보일 속성 이름들 (없으면 빈 목록) + List get labels => [if (hidden) tr('숨김'), if (readOnly) tr('읽기 전용'), if (system) tr('시스템')]; +} + +typedef _GetAttrsExC = Int32 Function(Pointer name, Int32 level, Pointer info); +typedef _GetAttrsExD = int Function(Pointer name, int level, Pointer info); + +/// [path] 의 속성 (읽지 못하면 null). 로컬 경로만 (원격은 부르지 않는다) +FileAttributes? readFileAttributes(String path) { + if (!Platform.isWindows) return FileAttributes(hidden: p.basename(path).startsWith('.')); + final name = path.toNativeUtf16(); + // WIN32_FILE_ATTRIBUTE_DATA: DWORD 속성 · FILETIME 만든 · 열어 본 · 바뀐 때 · DWORD 크기 (위 · 아래) = 36 바이트 + final info = calloc(36); + try { + final f = DynamicLibrary.open('kernel32.dll').lookupFunction<_GetAttrsExC, _GetAttrsExD>('GetFileAttributesExW'); + if (f(name, 0 /* GetFileExInfoStandard */, info) == 0) return null; + final b = ByteData.sublistView(info.asTypedList(36)); + final attrs = b.getUint32(0, Endian.little); + final ft = b.getUint32(4, Endian.little) | (b.getUint32(8, Endian.little) << 32); + // FILETIME: 1601-01-01 부터 100ns 단위 + final created = ft == 0 ? null : DateTime.fromMicrosecondsSinceEpoch(ft ~/ 10 - 11644473600000000, isUtc: true).toLocal(); + return FileAttributes(hidden: attrs & 0x2 != 0, readOnly: attrs & 0x1 != 0, system: attrs & 0x4 != 0, created: created); + } catch (_) { + return null; + } finally { + calloc.free(name); + calloc.free(info); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index c26f385..f914020 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -48,6 +48,7 @@ import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'quick_look.dart'; import 'file_tools.dart' show showFileHash, showFolderProperties; +import '../core/file_attributes.dart' show readFileAttributes; import 'explorer_drag.dart'; import 'archive_ui.dart'; import 'storage_page.dart'; @@ -3215,6 +3216,8 @@ class _ExplorerPageState extends State { Future _info(FileEntry e) async { final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await _Meta.of(c, e.path) : null; + // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만) + final attrs = isDav(e.path) ? null : readFileAttributes(e.path); if (!mounted) return; await showDialog( context: context, @@ -3224,7 +3227,9 @@ class _ExplorerPageState extends State { content: SelectableText([ e.path, '${tr('크기')}: ${formatSize(size)}', + if (attrs?.created != null) '${tr('만든 날')}: ${_date(attrs!.created!)}', if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}', + if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}', if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta', ].join('\n')), actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], diff --git a/lib/ui/file_tools.dart b/lib/ui/file_tools.dart index a3a5a8e..62a9087 100644 --- a/lib/ui/file_tools.dart +++ b/lib/ui/file_tools.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; +import '../core/file_attributes.dart'; import '../core/file_ops.dart' show formatSize; import '../l10n/tr.dart'; import 'theme.dart'; @@ -228,6 +229,7 @@ Future<({int files, int folders, int bytes, int links})> folderStats(String dir, Future showFolderProperties(BuildContext context, String dir) async { var cancelled = false; final stat = await FileStat.stat(dir); + final attrs = readFileAttributes(dir); if (!context.mounted) return; String date(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; @@ -253,8 +255,11 @@ Future showFolderProperties(BuildContext context, String dir) async { if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])), ], const SizedBox(height: 6), + // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 (보기만) + if (attrs?.created != null) Text(trf('만든 때: {0}', [date(attrs!.created!)])), Text(trf('바뀐 때: {0}', [date(stat.modified)])), Text(trf('열어 본 때: {0}', [date(stat.accessed)])), + Text(trf('속성: {0}', [attrs == null || attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')])), ]); }, ), diff --git a/test/file_attributes_test.dart b/test/file_attributes_test.dart new file mode 100644 index 0000000..b899278 --- /dev/null +++ b/test/file_attributes_test.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/file_attributes.dart'; +import 'package:path/path.dart' as p; + +/// P3 보충: 숨김 · 읽기 전용 · 만든 때 (Windows GetFileAttributesExW) - 시험 파일에만 attrib 로 속성을 켜고 끝에 끈다 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_attrs_')); + tearDown(() async { + if (Platform.isWindows) await Process.run('attrib', ['-h', '-r', '-s', p.join(tmp.path, '*'), '/s', '/d']); + tmp.deleteSync(recursive: true); + }); + + test('보통 파일: 속성 없음 · 만든 때는 지금 즈음', () { + final f = File(p.join(tmp.path, 'a.txt'))..writeAsStringSync('a'); + final a = readFileAttributes(f.path)!; + expect(a.labels, isEmpty); + if (Platform.isWindows) { + expect(a.created, isNotNull); + expect(DateTime.now().difference(a.created!).inMinutes.abs(), lessThan(5)); + } + }); + + test('숨김 · 읽기 전용 (Windows attrib) · 폴더', () async { + final f = File(p.join(tmp.path, 'b.txt'))..writeAsStringSync('b'); + final d = Directory(p.join(tmp.path, 'd'))..createSync(); + if (!Platform.isWindows) { + expect(readFileAttributes(p.join(tmp.path, '.hidden'))!.hidden, isTrue); + return; + } + await Process.run('attrib', ['+h', '+r', f.path]); + await Process.run('attrib', ['+h', d.path]); + final a = readFileAttributes(f.path)!; + expect((a.hidden, a.readOnly, a.system), (true, true, false)); + expect(a.labels, ['숨김', '읽기 전용']); + expect(readFileAttributes(d.path)!.hidden, isTrue); + expect(readFileAttributes(p.join(tmp.path, '없음')), isNull); + }); +}