Merge commit 'd29593b' into next

# Conflicts:
#	assets/l10n/en.json
#	assets/l10n/ja.json
#	assets/l10n/zh-Hans.json
trunc_fix
RyuJongJin 2026-10-18 02:02:22 +09:00
commit e8baa846bb
9 changed files with 266 additions and 9 deletions

View File

@ -1142,6 +1142,8 @@
"마지막으로: {0}": "To the last: {0}", "마지막으로: {0}": "To the last: {0}",
"막은 경로 {0}개": "{0} paths blocked", "막은 경로 {0}개": "{0} paths blocked",
"만든 그림 · 저장: {0}": "Made images · saved in: {0}", "만든 그림 · 저장: {0}": "Made images · saved in: {0}",
"만든 날": "Created",
"만든 때: {0}": "Created: {0}",
"만들 MKV 가 이미 있으면": "If the MKV already exists", "만들 MKV 가 이미 있으면": "If the MKV already exists",
"만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)",
"만들 파일: {0}": "File to create: {0}", "만들 파일: {0}": "File to create: {0}",
@ -1575,6 +1577,8 @@
"속도 제한": "Speed limit", "속도 제한": "Speed limit",
"속도 제한 (KB/s)": "Speed limit (KB/s)", "속도 제한 (KB/s)": "Speed limit (KB/s)",
"속도 제한 {0} KB/s": "Speed limit {0} KB/s", "속도 제한 {0} KB/s": "Speed limit {0} KB/s",
"속성": "Attributes",
"속성: {0}": "Attributes: {0}",
"수": "Wed", "수": "Wed",
"수정": "Edit", "수정": "Edit",
"수정 · 이동": "Edit · move", "수정 · 이동": "Edit · move",
@ -1600,6 +1604,7 @@
"시드 (-1 은 아무렇게나)": "Seed (-1 = random)", "시드 (-1 은 아무렇게나)": "Seed (-1 = random)",
"시드 {0}": "Seed {0}", "시드 {0}": "Seed {0}",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02", "시리즈: 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",
"시스템 DNS (끄기)": "System DNS (off)", "시스템 DNS (끄기)": "System DNS (off)",
"시스템 DNS 로 바꾸기": "Switch to system DNS", "시스템 DNS 로 바꾸기": "Switch to system DNS",
@ -1921,6 +1926,7 @@
"위 ⇄ 아래": "Top ⇄ bottom", "위 ⇄ 아래": "Top ⇄ bottom",
"위 폴더": "Parent folder", "위 폴더": "Parent folder",
"위 폴더로": "Go up", "위 폴더로": "Go up",
"위 폴더로 가기": "Go to a parent folder",
"위로": "Up", "위로": "Up",
"위아래": "Top and bottom", "위아래": "Top and bottom",
"위아래 ⇅": "Stacked ⇅", "위아래 ⇅": "Stacked ⇅",
@ -2092,6 +2098,7 @@
"일반 오류": "general failure", "일반 오류": "general failure",
"일본어": "Japanese", "일본어": "Japanese",
"일시정지": "Pause", "일시정지": "Pause",
"읽기 전용": "Read-only",
"읽기 전용 · 보기와 받기만 됩니다": "Read-only · view and download only", "읽기 전용 · 보기와 받기만 됩니다": "Read-only · view and download only",
"읽기 전용 공유입니다 (보기 · 받기만)": "This share is read-only (view and download only)", "읽기 전용 공유입니다 (보기 · 받기만)": "This share is read-only (view and download only)",
"읽기 전용 매체입니다": "Read-only media", "읽기 전용 매체입니다": "Read-only media",

View File

@ -1142,6 +1142,8 @@
"마지막으로: {0}": "最後へ: {0}", "마지막으로: {0}": "最後へ: {0}",
"막은 경로 {0}개": "ブロックしたパス {0} 件", "막은 경로 {0}개": "ブロックしたパス {0} 件",
"만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}",
"만든 날": "作成日",
"만든 때: {0}": "作成日時: {0}",
"만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき", "만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき",
"만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)",
"만들 파일: {0}": "作成するファイル: {0}", "만들 파일: {0}": "作成するファイル: {0}",
@ -1575,6 +1577,8 @@
"속도 제한": "速度制限", "속도 제한": "速度制限",
"속도 제한 (KB/s)": "速度制限 (KB/s)", "속도 제한 (KB/s)": "速度制限 (KB/s)",
"속도 제한 {0} KB/s": "速度制限 {0} KB/s", "속도 제한 {0} KB/s": "速度制限 {0} KB/s",
"속성": "属性",
"속성: {0}": "属性: {0}",
"수": "水", "수": "水",
"수정": "編集", "수정": "編集",
"수정 · 이동": "編集・移動", "수정 · 이동": "編集・移動",
@ -1600,6 +1604,7 @@
"시드 (-1 은 아무렇게나)": "シード (-1 はランダム)", "시드 (-1 은 아무렇게나)": "シード (-1 はランダム)",
"시드 {0}": "シード {0}", "시드 {0}": "シード {0}",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル",
"시스템": "システム",
"시스템 DNS": "システム DNS", "시스템 DNS": "システム DNS",
"시스템 DNS (끄기)": "システム DNS (オフ)", "시스템 DNS (끄기)": "システム DNS (オフ)",
"시스템 DNS 로 바꾸기": "システム DNS に切り替える", "시스템 DNS 로 바꾸기": "システム DNS に切り替える",
@ -1921,6 +1926,7 @@
"위 ⇄ 아래": "上 ⇄ 下", "위 ⇄ 아래": "上 ⇄ 下",
"위 폴더": "上のフォルダー", "위 폴더": "上のフォルダー",
"위 폴더로": "上のフォルダーへ", "위 폴더로": "上のフォルダーへ",
"위 폴더로 가기": "上のフォルダーへ移動",
"위로": "上へ", "위로": "上へ",
"위아래": "上下", "위아래": "上下",
"위아래 ⇅": "上下 ⇅", "위아래 ⇅": "上下 ⇅",
@ -2092,6 +2098,7 @@
"일반 오류": "一般エラー", "일반 오류": "一般エラー",
"일본어": "日本語", "일본어": "日本語",
"일시정지": "一時停止", "일시정지": "一時停止",
"읽기 전용": "読み取り専用",
"읽기 전용 · 보기와 받기만 됩니다": "読み取り専用 · 表示とダウンロードのみ", "읽기 전용 · 보기와 받기만 됩니다": "読み取り専用 · 表示とダウンロードのみ",
"읽기 전용 공유입니다 (보기 · 받기만)": "読み取り専用の共有です (表示 · ダウンロードのみ)", "읽기 전용 공유입니다 (보기 · 받기만)": "読み取り専用の共有です (表示 · ダウンロードのみ)",
"읽기 전용 매체입니다": "読み取り専用メディアです", "읽기 전용 매체입니다": "読み取り専用メディアです",

View File

@ -1142,6 +1142,8 @@
"마지막으로: {0}": "到最后: {0}", "마지막으로: {0}": "到最后: {0}",
"막은 경로 {0}개": "已阻止 {0} 个路径", "막은 경로 {0}개": "已阻止 {0} 个路径",
"만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}",
"만든 날": "创建日期",
"만든 때: {0}": "创建时间:{0}",
"만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时", "만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时",
"만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)",
"만들 파일: {0}": "要创建的文件: {0}", "만들 파일: {0}": "要创建的文件: {0}",
@ -1575,6 +1577,8 @@
"속도 제한": "速度限制", "속도 제한": "速度限制",
"속도 제한 (KB/s)": "速度限制 (KB/s)", "속도 제한 (KB/s)": "速度限制 (KB/s)",
"속도 제한 {0} KB/s": "速度限制 {0} KB/s", "속도 제한 {0} KB/s": "速度限制 {0} KB/s",
"속성": "属性",
"속성: {0}": "属性:{0}",
"수": "三", "수": "三",
"수정": "编辑", "수정": "编辑",
"수정 · 이동": "编辑 · 移动", "수정 · 이동": "编辑 · 移动",
@ -1600,6 +1604,7 @@
"시드 (-1 은 아무렇게나)": "种子 (-1 为随机)", "시드 (-1 은 아무렇게나)": "种子 (-1 为随机)",
"시드 {0}": "种子 {0}", "시드 {0}": "种子 {0}",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件",
"시스템": "系统",
"시스템 DNS": "系统 DNS", "시스템 DNS": "系统 DNS",
"시스템 DNS (끄기)": "系统 DNS (关闭)", "시스템 DNS (끄기)": "系统 DNS (关闭)",
"시스템 DNS 로 바꾸기": "切换为系统 DNS", "시스템 DNS 로 바꾸기": "切换为系统 DNS",
@ -1921,6 +1926,7 @@
"위 ⇄ 아래": "上 ⇄ 下", "위 ⇄ 아래": "上 ⇄ 下",
"위 폴더": "上级文件夹", "위 폴더": "上级文件夹",
"위 폴더로": "返回上级文件夹", "위 폴더로": "返回上级文件夹",
"위 폴더로 가기": "转到上级文件夹",
"위로": "向上", "위로": "向上",
"위아래": "上下", "위아래": "上下",
"위아래 ⇅": "上下 ⇅", "위아래 ⇅": "上下 ⇅",
@ -2092,6 +2098,7 @@
"일반 오류": "一般错误", "일반 오류": "一般错误",
"일본어": "日语", "일본어": "日语",
"일시정지": "暂停", "일시정지": "暂停",
"읽기 전용": "只读",
"읽기 전용 · 보기와 받기만 됩니다": "只读 · 仅可查看和下载", "읽기 전용 · 보기와 받기만 됩니다": "只读 · 仅可查看和下载",
"읽기 전용 공유입니다 (보기 · 받기만)": "这是只读共享 (仅可查看和下载)", "읽기 전용 공유입니다 (보기 · 받기만)": "这是只读共享 (仅可查看和下载)",
"읽기 전용 매체입니다": "只读介质", "읽기 전용 매체입니다": "只读介质",

View File

@ -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<NavigatorState>();
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<void> settle([int n = 8]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump();
}
}
Future<void> 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<void> 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<void>(builder: (_) => HexViewerPage(path: file.path)));
await settle(12);
await shot('hex');
await closeAll();
});
}
}
}

View File

@ -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<String> get labels => [if (hidden) tr('숨김'), if (readOnly) tr('읽기 전용'), if (system) tr('시스템')];
}
typedef _GetAttrsExC = Int32 Function(Pointer<Utf16> name, Int32 level, Pointer<Uint8> info);
typedef _GetAttrsExD = int Function(Pointer<Utf16> name, int level, Pointer<Uint8> 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<Uint8>(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);
}
}

View File

@ -54,6 +54,7 @@ import 'explorer_bookmarks.dart';
import 'explorer_search.dart'; import 'explorer_search.dart';
import 'quick_look.dart'; import 'quick_look.dart';
import 'file_tools.dart' show showFileHash, showFolderProperties; import 'file_tools.dart' show showFileHash, showFolderProperties;
import '../core/file_attributes.dart' show readFileAttributes;
import 'explorer_drag.dart'; import 'explorer_drag.dart';
import 'archive_ui.dart'; import 'archive_ui.dart';
import 'explorer_grid.dart'; import 'explorer_grid.dart';
@ -3584,6 +3585,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _info(FileEntry e) async { Future<void> _info(FileEntry e) async {
final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; 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; 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; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
@ -3593,7 +3596,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
content: SelectableText([ content: SelectableText([
e.path, e.path,
'${tr('크기')}: ${formatSize(size)}', '${tr('크기')}: ${formatSize(size)}',
if (attrs?.created != null) '${tr('만든 날')}: ${_date(attrs!.created!)}',
if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}', 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', if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta',
].join('\n')), ].join('\n')),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
@ -4073,16 +4078,46 @@ class _ExplorerPageState extends State<ExplorerPage> {
); );
} }
/// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터)
List<String> _ancestors(_Pane pane) {
final out = <String>[];
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( Widget _topBar() => AppTopBar(
nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync), nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
actions: AppActions(c: c), actions: AppActions(c: c),
middle: Row(children: [ middle: Row(children: [
Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 12), const SizedBox(width: 12),
// X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다
Expanded( Expanded(
child: Text(_ready ? _displayPath(_pane.current) : '', child: !_ready
? const SizedBox.shrink()
: PopupMenuButton<String>(
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)), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
), ),
),
PopupMenuButton<ExplorerButton>( PopupMenuButton<ExplorerButton>(
tooltip: tr('창 배치 · 버튼 구성'), tooltip: tr('창 배치 · 버튼 구성'),
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../core/file_attributes.dart';
import '../core/file_ops.dart' show formatSize; import '../core/file_ops.dart' show formatSize;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'theme.dart'; import 'theme.dart';
@ -112,7 +113,8 @@ Future<void> showFileHash(BuildContext context, String path) async {
); );
}), }),
); );
compare.dispose(); // 창이 닫히는 동안 (애니메이션) 글 칸이 아직 쓰므로 바로 dispose 하면 "used after being disposed" (15 캡처) - 닫힌 뒤에
Future<void>.delayed(const Duration(milliseconds: 500), compare.dispose);
} }
/// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기] /// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기]
@ -121,12 +123,12 @@ class HexViewerPage extends StatefulWidget {
final int chunk; final int chunk;
const HexViewerPage({super.key, required this.path, this.chunk = 64 * 1024}); const HexViewerPage({super.key, required this.path, this.chunk = 64 * 1024});
/// 한 줄 (16 바이트): "00000010 41 42 ... AB.." /// 한 줄 ([per] 바이트, 처음 16): "00000010 41 42 ... AB.."
static String line(int offset, List<int> bytes) { static String line(int offset, List<int> bytes, {int per = 16}) {
final hex = StringBuffer(); 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 < 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]); final text = String.fromCharCodes([for (final b in bytes) b >= 0x20 && b < 0x7f ? b : 0x2e]);
return '${offset.toRadixString(16).padLeft(8, '0')} $hex $text'; return '${offset.toRadixString(16).padLeft(8, '0')} $hex $text';
@ -163,7 +165,9 @@ class _HexViewerPageState extends State<HexViewerPage> {
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)), appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)),
body: _error != null body: _error != null
@ -176,7 +180,7 @@ class _HexViewerPageState extends State<HexViewerPage> {
itemCount: rows, itemCount: rows,
itemExtent: 18, itemExtent: 18,
itemBuilder: (_, i) => Text( 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), style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
maxLines: 1, maxLines: 1,
softWrap: false, softWrap: false,
@ -225,6 +229,7 @@ Future<({int files, int folders, int bytes, int links})> folderStats(String dir,
Future<void> showFolderProperties(BuildContext context, String dir) async { Future<void> showFolderProperties(BuildContext context, String dir) async {
var cancelled = false; var cancelled = false;
final stat = await FileStat.stat(dir); final stat = await FileStat.stat(dir);
final attrs = readFileAttributes(dir);
if (!context.mounted) return; if (!context.mounted) return;
String date(DateTime d) => 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')}'; '${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')}';
@ -250,8 +255,11 @@ Future<void> showFolderProperties(BuildContext context, String dir) async {
if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])), if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])),
], ],
const SizedBox(height: 6), 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.modified)])),
Text(trf('열어 본 때: {0}', [date(stat.accessed)])), Text(trf('열어 본 때: {0}', [date(stat.accessed)])),
Text(trf('속성: {0}', [attrs == null || attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')])),
]); ]);
}, },
), ),

View File

@ -196,6 +196,25 @@ void main() {
await calm(tester); 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<String>);
final n = items.evaluate().length;
expect(n, greaterThanOrEqualTo(2), reason: 'sub · 그 위 폴더들 (창의 맨 위까지)');
expect(tester.widget<PopupMenuItem<String>>(items.first).enabled, isFalse, reason: '지금 폴더는 고를 수 없음');
expect(tester.widget<PopupMenuItem<String>>(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<String>), findsNWidgets(n - 1));
});
testWidgets('X-plore b: 가운데 버튼 길게 누르기 = 짝 동작 - [복사] 길게 = 이동 창 · [삭제] 길게 = 영구 삭제 창', (tester) async { testWidgets('X-plore b: 가운데 버튼 길게 누르기 = 짝 동작 - [복사] 길게 = 이동 창 · [삭제] 길게 = 영구 삭제 창', (tester) async {
await open(tester); await open(tester);
await act(tester, () => tester.tap(find.text('doc.txt'))); await act(tester, () => tester.tap(find.text('doc.txt')));

View File

@ -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);
});
}